@taskforcehq/taskforce 0.3.170 → 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 +2 -2
- 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,2001 @@
|
|
|
1
|
+
import Stripe from 'stripe';
|
|
2
|
+
import { parseJsonBody } from '../routes.js';
|
|
3
|
+
import { resolveTaskforceCloudEnvironmentFromHostname } from '../../config/cloudEnvironment.js';
|
|
4
|
+
import { buildMockStripeHostedCheckoutUrl, completeMockStripeCheckoutSession, createMockStripeClient, isMockStripeClient, isMockStripeEnabled, readMockStripeCheckoutSession, renderMockStripeCheckoutHtml } from '../mockStripe.js';
|
|
5
|
+
const BILLING_CONFIG_KEY_PLAN = 'billing.config.v1';
|
|
6
|
+
const PRICE_METADATA_PLAN_VERSION = 'taskforce_plan_version_id';
|
|
7
|
+
const BILLING_DIAGNOSTIC_RETENTION_DAYS = 30;
|
|
8
|
+
const BILLING_DIAGNOSTIC_MAX_PAYLOAD_BYTES = 8 * 1024;
|
|
9
|
+
const TASKFORCE_PROXY_ORIGIN_HOST_HEADER = 'x-taskforce-origin-host';
|
|
10
|
+
const TASKFORCE_PROXY_ORIGIN_PROTO_HEADER = 'x-taskforce-origin-proto';
|
|
11
|
+
const TASKFORCE_PROXY_ORIGIN_BASE_URL_HEADER = 'x-taskforce-origin-base-url';
|
|
12
|
+
const TASKFORCE_PROXY_ORIGIN_RUNTIME_HEADER = 'x-taskforce-origin-runtime';
|
|
13
|
+
function parsePricingType(raw) {
|
|
14
|
+
const value = String(raw || '').trim().toLowerCase();
|
|
15
|
+
if (value === 'stripe' || value === 'free')
|
|
16
|
+
return value;
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
function buildFreePricingSyntheticId(planVersionId, interval) {
|
|
20
|
+
return `free:${planVersionId}:${interval}`;
|
|
21
|
+
}
|
|
22
|
+
function isoFromUnixSeconds(raw) {
|
|
23
|
+
const value = Number(raw);
|
|
24
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
25
|
+
return null;
|
|
26
|
+
return new Date(Math.floor(value) * 1000).toISOString();
|
|
27
|
+
}
|
|
28
|
+
function parseInterval(raw) {
|
|
29
|
+
const value = String(raw || '').trim().toLowerCase();
|
|
30
|
+
if (value === 'month' || value === 'year')
|
|
31
|
+
return value;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
function normalizeEmail(raw) {
|
|
35
|
+
return String(raw || '').trim().toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
function parseRequestUrl(req) {
|
|
38
|
+
return new URL(req.url || '/', 'https://taskforce.local');
|
|
39
|
+
}
|
|
40
|
+
async function listStripePriceOptions(intervalFilter) {
|
|
41
|
+
const stripe = getStripeClient();
|
|
42
|
+
const options = [];
|
|
43
|
+
let startingAfter;
|
|
44
|
+
while (true) {
|
|
45
|
+
const page = await stripe.prices.list({
|
|
46
|
+
active: true,
|
|
47
|
+
type: 'recurring',
|
|
48
|
+
limit: 100,
|
|
49
|
+
starting_after: startingAfter,
|
|
50
|
+
expand: ['data.product']
|
|
51
|
+
});
|
|
52
|
+
for (const price of page.data) {
|
|
53
|
+
const interval = parseInterval(price.recurring?.interval || '');
|
|
54
|
+
if (!interval)
|
|
55
|
+
continue;
|
|
56
|
+
if (intervalFilter && interval !== intervalFilter)
|
|
57
|
+
continue;
|
|
58
|
+
const product = typeof price.product === 'string' ? null : price.product;
|
|
59
|
+
options.push({
|
|
60
|
+
stripePriceId: price.id,
|
|
61
|
+
interval,
|
|
62
|
+
unitAmount: typeof price.unit_amount === 'number' ? price.unit_amount : null,
|
|
63
|
+
currency: price.currency ? String(price.currency) : null,
|
|
64
|
+
active: price.active === true,
|
|
65
|
+
nickname: price.nickname ? String(price.nickname) : null,
|
|
66
|
+
productId: typeof price.product === 'string' ? price.product : (product?.id ? String(product.id) : null),
|
|
67
|
+
productName: product?.deleted ? null : (product?.name ? String(product.name) : null)
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (!page.has_more || page.data.length === 0)
|
|
71
|
+
break;
|
|
72
|
+
startingAfter = page.data[page.data.length - 1]?.id;
|
|
73
|
+
if (!startingAfter)
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
return options.sort((a, b) => {
|
|
77
|
+
if (a.interval !== b.interval)
|
|
78
|
+
return a.interval.localeCompare(b.interval);
|
|
79
|
+
return a.stripePriceId.localeCompare(b.stripePriceId);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function resolveSubscriptionCurrentPeriodEnd(subscription) {
|
|
83
|
+
const itemPeriods = Array.isArray(subscription.items?.data)
|
|
84
|
+
? subscription.items.data
|
|
85
|
+
.map((item) => isoFromUnixSeconds(item?.current_period_end))
|
|
86
|
+
.filter((value) => Boolean(value))
|
|
87
|
+
: [];
|
|
88
|
+
if (itemPeriods.length === 0)
|
|
89
|
+
return null;
|
|
90
|
+
return itemPeriods.sort()[itemPeriods.length - 1] || null;
|
|
91
|
+
}
|
|
92
|
+
function resolveInvoiceSubscriptionId(invoice) {
|
|
93
|
+
const parentSubscription = String(invoice?.parent?.subscription_details?.subscription || '').trim();
|
|
94
|
+
if (parentSubscription)
|
|
95
|
+
return parentSubscription;
|
|
96
|
+
const lineSubscription = String(invoice.lines?.data?.[0]?.subscription || '').trim();
|
|
97
|
+
return lineSubscription;
|
|
98
|
+
}
|
|
99
|
+
function requestRuntimeMode(req) {
|
|
100
|
+
const value = String(req.headers['x-taskforce-runtime-mode'] || '').trim().toLowerCase();
|
|
101
|
+
return value === 'cloud' ? 'cloud' : 'local';
|
|
102
|
+
}
|
|
103
|
+
function requestPricingEnvironment(req) {
|
|
104
|
+
const hostCandidates = [
|
|
105
|
+
String(req.headers.host || '').trim(),
|
|
106
|
+
String(req.headers['x-forwarded-host'] || '').trim()
|
|
107
|
+
];
|
|
108
|
+
for (const rawHost of hostCandidates) {
|
|
109
|
+
const hostname = rawHost.split(',')[0].trim().replace(/:\d+$/, '');
|
|
110
|
+
const environment = resolveTaskforceCloudEnvironmentFromHostname(hostname);
|
|
111
|
+
if (environment)
|
|
112
|
+
return environment;
|
|
113
|
+
}
|
|
114
|
+
return 'production';
|
|
115
|
+
}
|
|
116
|
+
function parseBoolean(value, fallback = false) {
|
|
117
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
118
|
+
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on')
|
|
119
|
+
return true;
|
|
120
|
+
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off')
|
|
121
|
+
return false;
|
|
122
|
+
return fallback;
|
|
123
|
+
}
|
|
124
|
+
function parsePositiveIntBounded(raw, fallback, min, max) {
|
|
125
|
+
const parsed = Number.parseInt(String(raw || '').trim(), 10);
|
|
126
|
+
if (!Number.isFinite(parsed))
|
|
127
|
+
return fallback;
|
|
128
|
+
return Math.max(min, Math.min(max, parsed));
|
|
129
|
+
}
|
|
130
|
+
function readHeaderFirstValue(raw) {
|
|
131
|
+
return String(raw || '').split(',').map((value) => value.trim()).find(Boolean) || '';
|
|
132
|
+
}
|
|
133
|
+
function normalizeOriginHost(raw) {
|
|
134
|
+
return readHeaderFirstValue(raw).replace(/\/+$/, '');
|
|
135
|
+
}
|
|
136
|
+
function normalizeOriginProto(raw, fallback = 'https') {
|
|
137
|
+
return readHeaderFirstValue(raw).toLowerCase() === 'http' ? 'http' : fallback;
|
|
138
|
+
}
|
|
139
|
+
function resolveRequestProto(req, raw, fallback = 'https') {
|
|
140
|
+
const normalized = readHeaderFirstValue(raw).toLowerCase();
|
|
141
|
+
if (normalized === 'http')
|
|
142
|
+
return 'http';
|
|
143
|
+
if (normalized === 'https')
|
|
144
|
+
return 'https';
|
|
145
|
+
return req.socket?.encrypted ? 'https' : fallback;
|
|
146
|
+
}
|
|
147
|
+
function isLocalDevelopmentHost(raw) {
|
|
148
|
+
const host = normalizeOriginHost(raw).replace(/:\d+$/, '').toLowerCase();
|
|
149
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
|
150
|
+
}
|
|
151
|
+
function resolveRequestOrigin(req) {
|
|
152
|
+
const explicitBaseUrl = readHeaderFirstValue(req.headers[TASKFORCE_PROXY_ORIGIN_BASE_URL_HEADER]);
|
|
153
|
+
const explicitHost = normalizeOriginHost(req.headers[TASKFORCE_PROXY_ORIGIN_HOST_HEADER]);
|
|
154
|
+
const explicitProto = resolveRequestProto(req, req.headers[TASKFORCE_PROXY_ORIGIN_PROTO_HEADER], 'http');
|
|
155
|
+
const originRuntime = readHeaderFirstValue(req.headers[TASKFORCE_PROXY_ORIGIN_RUNTIME_HEADER]).toLowerCase();
|
|
156
|
+
if (explicitBaseUrl) {
|
|
157
|
+
try {
|
|
158
|
+
const parsed = new URL(explicitBaseUrl);
|
|
159
|
+
const proto = parsed.protocol === 'http:' ? 'http' : 'https';
|
|
160
|
+
return {
|
|
161
|
+
baseUrl: `${proto}://${parsed.host.replace(/\/+$/, '')}`,
|
|
162
|
+
host: parsed.host.replace(/\/+$/, ''),
|
|
163
|
+
proto,
|
|
164
|
+
source: 'explicit-proxy-origin',
|
|
165
|
+
localProxyRequest: originRuntime === 'local-proxy' || isLocalDevelopmentHost(parsed.host)
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// Fall through to explicit host/proto parsing.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (explicitHost) {
|
|
173
|
+
return {
|
|
174
|
+
baseUrl: `${explicitProto}://${explicitHost}`,
|
|
175
|
+
host: explicitHost,
|
|
176
|
+
proto: explicitProto,
|
|
177
|
+
source: 'explicit-proxy-origin',
|
|
178
|
+
localProxyRequest: originRuntime === 'local-proxy' || isLocalDevelopmentHost(explicitHost)
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const forwardedHost = normalizeOriginHost(req.headers['x-forwarded-host']);
|
|
182
|
+
const forwardedProto = resolveRequestProto(req, req.headers['x-forwarded-proto'], 'http');
|
|
183
|
+
if (forwardedHost) {
|
|
184
|
+
return {
|
|
185
|
+
baseUrl: `${forwardedProto}://${forwardedHost}`,
|
|
186
|
+
host: forwardedHost,
|
|
187
|
+
proto: forwardedProto,
|
|
188
|
+
source: 'forwarded',
|
|
189
|
+
localProxyRequest: isLocalDevelopmentHost(forwardedHost)
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const requestHost = normalizeOriginHost(req.headers.host || 'localhost');
|
|
193
|
+
const requestProto = resolveRequestProto(req, req.headers['x-forwarded-proto'], 'http');
|
|
194
|
+
return {
|
|
195
|
+
baseUrl: `${requestProto}://${requestHost}`,
|
|
196
|
+
host: requestHost,
|
|
197
|
+
proto: requestProto,
|
|
198
|
+
source: 'request-host',
|
|
199
|
+
localProxyRequest: isLocalDevelopmentHost(requestHost)
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function resolveBaseUrl(req) {
|
|
203
|
+
const origin = resolveRequestOrigin(req);
|
|
204
|
+
if (origin.localProxyRequest && !isLocalDevelopmentHost(origin.host)) {
|
|
205
|
+
throw new Error(`Billing return URL origin mismatch: expected local development host but resolved ${origin.baseUrl}`);
|
|
206
|
+
}
|
|
207
|
+
return origin.baseUrl;
|
|
208
|
+
}
|
|
209
|
+
async function readRawBody(req, maxBytes = 1_048_576) {
|
|
210
|
+
const chunks = [];
|
|
211
|
+
let total = 0;
|
|
212
|
+
for await (const chunk of req) {
|
|
213
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
214
|
+
total += buf.length;
|
|
215
|
+
if (total > maxBytes) {
|
|
216
|
+
const error = new Error(`Payload too large (max ${maxBytes} bytes)`);
|
|
217
|
+
error.statusCode = 413;
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
chunks.push(buf);
|
|
221
|
+
}
|
|
222
|
+
return Buffer.concat(chunks);
|
|
223
|
+
}
|
|
224
|
+
function mapStripeStatusToCanonicalState(statusRaw) {
|
|
225
|
+
const status = String(statusRaw || '').trim().toLowerCase();
|
|
226
|
+
if (status === 'trialing')
|
|
227
|
+
return 'trialing';
|
|
228
|
+
if (status === 'active')
|
|
229
|
+
return 'active';
|
|
230
|
+
if (status === 'past_due')
|
|
231
|
+
return 'grace';
|
|
232
|
+
return 'canceled';
|
|
233
|
+
}
|
|
234
|
+
function isMissingStripeCustomerError(error) {
|
|
235
|
+
const code = String(error?.code || '').trim().toLowerCase();
|
|
236
|
+
const message = String(error?.message || '').trim().toLowerCase();
|
|
237
|
+
return code === 'resource_missing' && message.includes('customer');
|
|
238
|
+
}
|
|
239
|
+
function redactStripePayload(input) {
|
|
240
|
+
const blockedKeys = new Set([
|
|
241
|
+
'billing_details',
|
|
242
|
+
'customer_details',
|
|
243
|
+
'payment_method',
|
|
244
|
+
'payment_method_details',
|
|
245
|
+
'payment_intent',
|
|
246
|
+
'charges',
|
|
247
|
+
'source',
|
|
248
|
+
'sources',
|
|
249
|
+
'card',
|
|
250
|
+
'default_source'
|
|
251
|
+
]);
|
|
252
|
+
const visit = (value, depth) => {
|
|
253
|
+
if (depth > 6)
|
|
254
|
+
return '[truncated-depth]';
|
|
255
|
+
if (value === null || value === undefined)
|
|
256
|
+
return value;
|
|
257
|
+
if (Array.isArray(value))
|
|
258
|
+
return value.slice(0, 30).map((item) => visit(item, depth + 1));
|
|
259
|
+
if (typeof value !== 'object')
|
|
260
|
+
return value;
|
|
261
|
+
const source = value;
|
|
262
|
+
const out = {};
|
|
263
|
+
for (const [key, child] of Object.entries(source)) {
|
|
264
|
+
if (blockedKeys.has(key))
|
|
265
|
+
continue;
|
|
266
|
+
out[key] = visit(child, depth + 1);
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
};
|
|
270
|
+
const redacted = visit(input, 0);
|
|
271
|
+
return redacted && typeof redacted === 'object' && !Array.isArray(redacted)
|
|
272
|
+
? redacted
|
|
273
|
+
: {};
|
|
274
|
+
}
|
|
275
|
+
function boundedPayloadExcerpt(payload) {
|
|
276
|
+
const raw = JSON.stringify(payload);
|
|
277
|
+
if (Buffer.byteLength(raw, 'utf8') <= BILLING_DIAGNOSTIC_MAX_PAYLOAD_BYTES)
|
|
278
|
+
return raw;
|
|
279
|
+
const truncated = raw.slice(0, BILLING_DIAGNOSTIC_MAX_PAYLOAD_BYTES);
|
|
280
|
+
return `${truncated}...[truncated]`;
|
|
281
|
+
}
|
|
282
|
+
function parseBillingConfig(raw) {
|
|
283
|
+
const fallback = {
|
|
284
|
+
tiers: {},
|
|
285
|
+
taxEnabled: false,
|
|
286
|
+
billingAddress: 'auto'
|
|
287
|
+
};
|
|
288
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
289
|
+
return fallback;
|
|
290
|
+
const record = raw;
|
|
291
|
+
const billingAddress = String(record.billing_address || '').trim().toLowerCase() === 'required'
|
|
292
|
+
? 'required'
|
|
293
|
+
: 'auto';
|
|
294
|
+
return {
|
|
295
|
+
tiers: record.tiers && typeof record.tiers === 'object' && !Array.isArray(record.tiers)
|
|
296
|
+
? record.tiers
|
|
297
|
+
: {},
|
|
298
|
+
taxEnabled: parseBoolean(record.tax_enabled, false),
|
|
299
|
+
billingAddress
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function billingDisabledResponse(res) {
|
|
303
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
304
|
+
res.end(JSON.stringify({ error: 'Billing is disabled.' }));
|
|
305
|
+
}
|
|
306
|
+
function cloudOnlyResponse(res) {
|
|
307
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
308
|
+
res.end(JSON.stringify({ error: 'Billing is only available in cloud runtime.' }));
|
|
309
|
+
}
|
|
310
|
+
function localPricingProxyRequiredResponse(res) {
|
|
311
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
312
|
+
res.end(JSON.stringify({ error: 'Pricing must be served through the configured cloud pricing proxy in local runtime.' }));
|
|
313
|
+
}
|
|
314
|
+
function getStripeClient() {
|
|
315
|
+
const key = String(process.env.TASKFORCE_STRIPE_SECRET_KEY || '').trim();
|
|
316
|
+
if (!key) {
|
|
317
|
+
const error = new Error('TASKFORCE_STRIPE_SECRET_KEY is not configured.');
|
|
318
|
+
error.statusCode = 500;
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
if (isMockStripeEnabled(key)) {
|
|
322
|
+
return createMockStripeClient();
|
|
323
|
+
}
|
|
324
|
+
return new Stripe(key);
|
|
325
|
+
}
|
|
326
|
+
function resolveHostedServerBaseUrl(req) {
|
|
327
|
+
const host = normalizeOriginHost(req.headers.host || '127.0.0.1');
|
|
328
|
+
const proto = normalizeOriginProto(req.headers['x-forwarded-proto'], 'http');
|
|
329
|
+
return `${proto}://${host}`;
|
|
330
|
+
}
|
|
331
|
+
function readBillingConfig(core) {
|
|
332
|
+
const db = core.getDatabaseAdapter();
|
|
333
|
+
const tenantId = core.getTenantId();
|
|
334
|
+
const row = db.prepare(`
|
|
335
|
+
SELECT payload
|
|
336
|
+
FROM system_settings
|
|
337
|
+
WHERE tenant_id = ? AND settings_key = ?
|
|
338
|
+
LIMIT 1
|
|
339
|
+
`).get(tenantId, BILLING_CONFIG_KEY_PLAN);
|
|
340
|
+
if (!row?.payload)
|
|
341
|
+
return parseBillingConfig({});
|
|
342
|
+
try {
|
|
343
|
+
return parseBillingConfig(JSON.parse(row.payload));
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return parseBillingConfig({});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function writeBillingConfig(core, payload) {
|
|
350
|
+
const db = core.getDatabaseAdapter();
|
|
351
|
+
const tenantId = core.getTenantId();
|
|
352
|
+
const now = new Date().toISOString();
|
|
353
|
+
db.prepare(`
|
|
354
|
+
INSERT INTO system_settings (tenant_id, settings_key, payload, updated_at)
|
|
355
|
+
VALUES (?, ?, ?, ?)
|
|
356
|
+
ON CONFLICT (tenant_id, settings_key) DO UPDATE SET
|
|
357
|
+
payload = excluded.payload,
|
|
358
|
+
updated_at = excluded.updated_at
|
|
359
|
+
`).run(tenantId, BILLING_CONFIG_KEY_PLAN, JSON.stringify({
|
|
360
|
+
tiers: payload.tiers,
|
|
361
|
+
tax_enabled: payload.taxEnabled,
|
|
362
|
+
billing_address: payload.billingAddress
|
|
363
|
+
}), now);
|
|
364
|
+
}
|
|
365
|
+
function resolveStripePriceIdForPlanVersion(core, planVersionIdRaw, intervalRaw) {
|
|
366
|
+
const planVersionId = String(planVersionIdRaw || '').trim();
|
|
367
|
+
const interval = parseInterval(intervalRaw);
|
|
368
|
+
if (!planVersionId || !interval)
|
|
369
|
+
return null;
|
|
370
|
+
const db = core.getDatabaseAdapter();
|
|
371
|
+
const tenantId = core.getTenantId();
|
|
372
|
+
const row = db.prepare(`
|
|
373
|
+
SELECT stripe_price_id
|
|
374
|
+
FROM billing_price_mappings
|
|
375
|
+
WHERE tenant_id = ?
|
|
376
|
+
AND plan_version_id = ?
|
|
377
|
+
AND billing_interval = ?
|
|
378
|
+
AND active = 1
|
|
379
|
+
AND COALESCE(pricing_type, 'stripe') = 'stripe'
|
|
380
|
+
LIMIT 1
|
|
381
|
+
`).get(tenantId, planVersionId, interval);
|
|
382
|
+
return row?.stripe_price_id ? String(row.stripe_price_id) : null;
|
|
383
|
+
}
|
|
384
|
+
function resolveActivePricingForPlanVersion(core, planVersionIdRaw, intervalRaw) {
|
|
385
|
+
const planVersionId = String(planVersionIdRaw || '').trim();
|
|
386
|
+
const interval = parseInterval(intervalRaw);
|
|
387
|
+
if (!planVersionId || !interval)
|
|
388
|
+
return null;
|
|
389
|
+
const db = core.getDatabaseAdapter();
|
|
390
|
+
const tenantId = core.getTenantId();
|
|
391
|
+
const row = db.prepare(`
|
|
392
|
+
SELECT stripe_price_id, pricing_type
|
|
393
|
+
FROM billing_price_mappings
|
|
394
|
+
WHERE tenant_id = ?
|
|
395
|
+
AND plan_version_id = ?
|
|
396
|
+
AND billing_interval = ?
|
|
397
|
+
AND active = 1
|
|
398
|
+
LIMIT 1
|
|
399
|
+
`).get(tenantId, planVersionId, interval);
|
|
400
|
+
if (!row)
|
|
401
|
+
return null;
|
|
402
|
+
const pricingType = parsePricingType(row.pricing_type) || 'stripe';
|
|
403
|
+
return {
|
|
404
|
+
pricingType,
|
|
405
|
+
stripePriceId: pricingType === 'stripe' && row.stripe_price_id ? String(row.stripe_price_id) : null
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function resolvePlanVersionIdForPriceIdFallback(core, priceIdRaw) {
|
|
409
|
+
const priceId = String(priceIdRaw || '').trim();
|
|
410
|
+
if (!priceId)
|
|
411
|
+
return { planVersionId: null, reason: 'missing' };
|
|
412
|
+
const db = core.getDatabaseAdapter();
|
|
413
|
+
const tenantId = core.getTenantId();
|
|
414
|
+
const rows = db.prepare(`
|
|
415
|
+
SELECT DISTINCT plan_version_id
|
|
416
|
+
FROM billing_price_mappings
|
|
417
|
+
WHERE tenant_id = ?
|
|
418
|
+
AND stripe_price_id = ?
|
|
419
|
+
AND active = 1
|
|
420
|
+
ORDER BY plan_version_id ASC
|
|
421
|
+
`).all(tenantId, priceId);
|
|
422
|
+
if (rows.length === 0)
|
|
423
|
+
return { planVersionId: null, reason: 'missing' };
|
|
424
|
+
if (rows.length > 1)
|
|
425
|
+
return { planVersionId: null, reason: 'ambiguous' };
|
|
426
|
+
return {
|
|
427
|
+
planVersionId: rows[0]?.plan_version_id ? String(rows[0].plan_version_id) : null,
|
|
428
|
+
reason: null
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
function resolvePlanVersionIdForBillingEvent(core, candidatePlanVersionIds, fallbackPriceId) {
|
|
432
|
+
const db = core.getDatabaseAdapter();
|
|
433
|
+
const tenantId = core.getTenantId();
|
|
434
|
+
for (const candidate of candidatePlanVersionIds) {
|
|
435
|
+
const planVersionId = String(candidate || '').trim();
|
|
436
|
+
if (!planVersionId)
|
|
437
|
+
continue;
|
|
438
|
+
const row = db.prepare(`
|
|
439
|
+
SELECT plan_version_id
|
|
440
|
+
FROM plan_versions
|
|
441
|
+
WHERE tenant_id = ? AND plan_version_id = ?
|
|
442
|
+
LIMIT 1
|
|
443
|
+
`).get(tenantId, planVersionId);
|
|
444
|
+
if (row?.plan_version_id) {
|
|
445
|
+
return { planVersionId: String(row.plan_version_id), source: 'metadata', reason: null };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const fallback = resolvePlanVersionIdForPriceIdFallback(core, fallbackPriceId);
|
|
449
|
+
return {
|
|
450
|
+
planVersionId: fallback.planVersionId,
|
|
451
|
+
source: fallback.planVersionId ? 'price' : null,
|
|
452
|
+
reason: fallback.reason
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
function resolvePlanIdForVersion(core, planVersionIdRaw) {
|
|
456
|
+
const planVersionId = String(planVersionIdRaw || '').trim();
|
|
457
|
+
if (!planVersionId)
|
|
458
|
+
return null;
|
|
459
|
+
const db = core.getDatabaseAdapter();
|
|
460
|
+
const tenantId = core.getTenantId();
|
|
461
|
+
const row = db.prepare(`
|
|
462
|
+
SELECT plan_id
|
|
463
|
+
FROM plan_versions
|
|
464
|
+
WHERE tenant_id = ? AND plan_version_id = ?
|
|
465
|
+
LIMIT 1
|
|
466
|
+
`).get(tenantId, planVersionId);
|
|
467
|
+
return row?.plan_id ? String(row.plan_id) : null;
|
|
468
|
+
}
|
|
469
|
+
function getEntitlementRow(core, userIdRaw) {
|
|
470
|
+
const userId = String(userIdRaw || '').trim();
|
|
471
|
+
if (!userId)
|
|
472
|
+
return null;
|
|
473
|
+
const db = core.getDatabaseAdapter();
|
|
474
|
+
const tenantId = core.getTenantId();
|
|
475
|
+
const row = db.prepare(`
|
|
476
|
+
SELECT account_id, plan_version_id, entitlement_state
|
|
477
|
+
FROM account_entitlements
|
|
478
|
+
WHERE tenant_id = ? AND account_id = ?
|
|
479
|
+
LIMIT 1
|
|
480
|
+
`).get(tenantId, userId);
|
|
481
|
+
return row || null;
|
|
482
|
+
}
|
|
483
|
+
function upsertCanonicalEntitlement(core, input) {
|
|
484
|
+
const db = core.getDatabaseAdapter();
|
|
485
|
+
const tenantId = core.getTenantId();
|
|
486
|
+
const now = new Date().toISOString();
|
|
487
|
+
db.prepare(`
|
|
488
|
+
INSERT INTO account_entitlements (account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at)
|
|
489
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
490
|
+
ON CONFLICT (account_id) DO UPDATE SET
|
|
491
|
+
plan_version_id = excluded.plan_version_id,
|
|
492
|
+
entitlement_state = excluded.entitlement_state,
|
|
493
|
+
effective_at = excluded.effective_at,
|
|
494
|
+
effective_until = excluded.effective_until,
|
|
495
|
+
updated_by = excluded.updated_by,
|
|
496
|
+
updated_at = excluded.updated_at
|
|
497
|
+
`).run(input.userId, tenantId, input.planVersionId, input.state, input.effectiveAt || now, input.effectiveUntil || null, input.updatedBy || 'stripe-webhook', now, now);
|
|
498
|
+
}
|
|
499
|
+
function upsertBillingMirror(core, input) {
|
|
500
|
+
const db = core.getDatabaseAdapter();
|
|
501
|
+
const tenantId = core.getTenantId();
|
|
502
|
+
const now = new Date().toISOString();
|
|
503
|
+
const existingRow = db.prepare(`
|
|
504
|
+
SELECT tier, entitlement_state
|
|
505
|
+
FROM user_billing
|
|
506
|
+
WHERE tenant_id = ? AND user_id = ?
|
|
507
|
+
LIMIT 1
|
|
508
|
+
`).get(tenantId, input.userId);
|
|
509
|
+
const resolvedPlanId = String(input.planId || existingRow?.tier || '').trim() || '';
|
|
510
|
+
const resolvedCanonicalState = String(input.canonicalState || existingRow?.entitlement_state || 'active').trim() || 'active';
|
|
511
|
+
db.prepare(`
|
|
512
|
+
INSERT INTO user_billing (
|
|
513
|
+
user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval,
|
|
514
|
+
entitlement_state, trial_end, current_period_end, effective_until, created_at, updated_at
|
|
515
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
516
|
+
ON CONFLICT (user_id) DO UPDATE SET
|
|
517
|
+
stripe_customer_id = COALESCE(excluded.stripe_customer_id, user_billing.stripe_customer_id),
|
|
518
|
+
stripe_subscription_id = COALESCE(excluded.stripe_subscription_id, user_billing.stripe_subscription_id),
|
|
519
|
+
stripe_price_id = COALESCE(excluded.stripe_price_id, user_billing.stripe_price_id),
|
|
520
|
+
stripe_status = COALESCE(excluded.stripe_status, user_billing.stripe_status),
|
|
521
|
+
tier = COALESCE(excluded.tier, user_billing.tier),
|
|
522
|
+
billing_interval = COALESCE(excluded.billing_interval, user_billing.billing_interval),
|
|
523
|
+
entitlement_state = COALESCE(excluded.entitlement_state, user_billing.entitlement_state),
|
|
524
|
+
trial_end = COALESCE(excluded.trial_end, user_billing.trial_end),
|
|
525
|
+
current_period_end = COALESCE(excluded.current_period_end, user_billing.current_period_end),
|
|
526
|
+
effective_until = COALESCE(excluded.effective_until, user_billing.effective_until),
|
|
527
|
+
updated_at = excluded.updated_at
|
|
528
|
+
`).run(input.userId, tenantId, input.stripeCustomerId || null, input.stripeSubscriptionId || null, input.stripePriceId || null, input.stripeStatus || null, resolvedPlanId, input.billingInterval || null, resolvedCanonicalState, input.trialEnd || null, input.currentPeriodEnd || null, input.effectiveUntil || null, now, now);
|
|
529
|
+
}
|
|
530
|
+
function applySubscriptionSnapshot(core, input) {
|
|
531
|
+
const { userId, subscription, source } = input;
|
|
532
|
+
const customerId = String(subscription.customer || '').trim();
|
|
533
|
+
const stripeStatus = String(subscription.status || '').trim().toLowerCase();
|
|
534
|
+
const priceId = String(subscription.items?.data?.[0]?.price?.id || '').trim();
|
|
535
|
+
const billingInterval = parseInterval(subscription.items?.data?.[0]?.price?.recurring?.interval || '');
|
|
536
|
+
const planVersionResolution = resolvePlanVersionIdForBillingEvent(core, [
|
|
537
|
+
subscription.metadata?.planVersionId,
|
|
538
|
+
subscription.metadata?.plan_version_id
|
|
539
|
+
], priceId);
|
|
540
|
+
const planVersionId = planVersionResolution.planVersionId;
|
|
541
|
+
const existingEntitlement = getEntitlementRow(core, userId);
|
|
542
|
+
const nowIso = new Date().toISOString();
|
|
543
|
+
const currentPeriodEnd = resolveSubscriptionCurrentPeriodEnd(subscription);
|
|
544
|
+
const trialEnd = isoFromUnixSeconds(subscription.trial_end);
|
|
545
|
+
const canceledState = stripeStatus === 'canceled'
|
|
546
|
+
? (currentPeriodEnd && currentPeriodEnd > nowIso ? 'active' : 'canceled')
|
|
547
|
+
: null;
|
|
548
|
+
const canonicalState = canceledState || mapStripeStatusToCanonicalState(stripeStatus);
|
|
549
|
+
const effectiveUntil = stripeStatus === 'canceled'
|
|
550
|
+
? currentPeriodEnd
|
|
551
|
+
: (currentPeriodEnd || null);
|
|
552
|
+
upsertBillingMirror(core, {
|
|
553
|
+
userId,
|
|
554
|
+
stripeCustomerId: customerId || null,
|
|
555
|
+
stripeSubscriptionId: String(subscription.id || '').trim() || null,
|
|
556
|
+
stripePriceId: priceId || null,
|
|
557
|
+
stripeStatus: stripeStatus || null,
|
|
558
|
+
planId: planVersionId ? resolvePlanIdForVersion(core, planVersionId) : null,
|
|
559
|
+
billingInterval: billingInterval || null,
|
|
560
|
+
canonicalState,
|
|
561
|
+
trialEnd,
|
|
562
|
+
currentPeriodEnd,
|
|
563
|
+
effectiveUntil
|
|
564
|
+
});
|
|
565
|
+
if (planVersionId) {
|
|
566
|
+
upsertCanonicalEntitlement(core, {
|
|
567
|
+
userId,
|
|
568
|
+
planVersionId: planVersionId || existingEntitlement?.plan_version_id || core.getDefaultPlanVersionIdByMode('personal', { allowFallback: true }),
|
|
569
|
+
state: canonicalState,
|
|
570
|
+
effectiveAt: trialEnd || nowIso,
|
|
571
|
+
effectiveUntil,
|
|
572
|
+
updatedBy: source
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
return { planVersionId, canonicalState };
|
|
576
|
+
}
|
|
577
|
+
async function reconcileBillingFromStripe(core, userIdRaw, subscriptionIdRaw) {
|
|
578
|
+
const userId = String(userIdRaw || '').trim();
|
|
579
|
+
const subscriptionId = String(subscriptionIdRaw || '').trim();
|
|
580
|
+
if (!userId || !subscriptionId)
|
|
581
|
+
return false;
|
|
582
|
+
const stripe = getStripeClient();
|
|
583
|
+
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
|
584
|
+
applySubscriptionSnapshot(core, {
|
|
585
|
+
userId,
|
|
586
|
+
subscription,
|
|
587
|
+
source: 'checkout-confirm'
|
|
588
|
+
});
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
function resolveUserIdByCustomer(core, customerIdRaw) {
|
|
592
|
+
const customerId = String(customerIdRaw || '').trim();
|
|
593
|
+
if (!customerId)
|
|
594
|
+
return null;
|
|
595
|
+
const db = core.getDatabaseAdapter();
|
|
596
|
+
const tenantId = core.getTenantId();
|
|
597
|
+
const row = db.prepare(`
|
|
598
|
+
SELECT user_id
|
|
599
|
+
FROM user_billing
|
|
600
|
+
WHERE tenant_id = ? AND stripe_customer_id = ?
|
|
601
|
+
LIMIT 1
|
|
602
|
+
`).get(tenantId, customerId);
|
|
603
|
+
return row?.user_id ? String(row.user_id) : null;
|
|
604
|
+
}
|
|
605
|
+
function resolveUserIdBySubscription(core, subscriptionIdRaw) {
|
|
606
|
+
const subscriptionId = String(subscriptionIdRaw || '').trim();
|
|
607
|
+
if (!subscriptionId)
|
|
608
|
+
return null;
|
|
609
|
+
const db = core.getDatabaseAdapter();
|
|
610
|
+
const tenantId = core.getTenantId();
|
|
611
|
+
const row = db.prepare(`
|
|
612
|
+
SELECT user_id
|
|
613
|
+
FROM user_billing
|
|
614
|
+
WHERE tenant_id = ? AND stripe_subscription_id = ?
|
|
615
|
+
LIMIT 1
|
|
616
|
+
`).get(tenantId, subscriptionId);
|
|
617
|
+
return row?.user_id ? String(row.user_id) : null;
|
|
618
|
+
}
|
|
619
|
+
function getBillingMirrorRow(core, userIdRaw) {
|
|
620
|
+
const userId = String(userIdRaw || '').trim();
|
|
621
|
+
if (!userId)
|
|
622
|
+
return null;
|
|
623
|
+
const db = core.getDatabaseAdapter();
|
|
624
|
+
const tenantId = core.getTenantId();
|
|
625
|
+
return db.prepare(`
|
|
626
|
+
SELECT stripe_status, stripe_customer_id, stripe_subscription_id, stripe_price_id, billing_interval, trial_end, current_period_end, effective_until, entitlement_state, tier
|
|
627
|
+
FROM user_billing
|
|
628
|
+
WHERE tenant_id = ? AND user_id = ?
|
|
629
|
+
LIMIT 1
|
|
630
|
+
`).get(tenantId, userId);
|
|
631
|
+
}
|
|
632
|
+
function isStripeManagedSubscriptionStatus(statusRaw) {
|
|
633
|
+
const status = String(statusRaw || '').trim().toLowerCase();
|
|
634
|
+
return status === 'trialing'
|
|
635
|
+
|| status === 'active'
|
|
636
|
+
|| status === 'past_due'
|
|
637
|
+
|| status === 'unpaid'
|
|
638
|
+
|| status === 'paused';
|
|
639
|
+
}
|
|
640
|
+
async function discoverStripeAccountStateForUser(core, input) {
|
|
641
|
+
const stripe = getStripeClient();
|
|
642
|
+
const customerIds = [];
|
|
643
|
+
const seenCustomerIds = new Set();
|
|
644
|
+
const pushCustomerId = (raw) => {
|
|
645
|
+
const customerId = String(raw || '').trim();
|
|
646
|
+
if (!customerId || seenCustomerIds.has(customerId))
|
|
647
|
+
return;
|
|
648
|
+
seenCustomerIds.add(customerId);
|
|
649
|
+
customerIds.push(customerId);
|
|
650
|
+
};
|
|
651
|
+
pushCustomerId(input.currentCustomerId);
|
|
652
|
+
const normalizedEmail = normalizeEmail(input.normalizedEmail);
|
|
653
|
+
if (normalizedEmail && stripe.customers?.list) {
|
|
654
|
+
const customers = await stripe.customers.list({
|
|
655
|
+
email: normalizedEmail,
|
|
656
|
+
limit: 20
|
|
657
|
+
});
|
|
658
|
+
if (Array.isArray(customers?.data)) {
|
|
659
|
+
for (const customer of customers.data) {
|
|
660
|
+
pushCustomerId(customer?.id);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const activeSubscriptions = [];
|
|
665
|
+
const seenSubscriptionIds = new Set();
|
|
666
|
+
for (const customerId of customerIds) {
|
|
667
|
+
const subscriptions = await stripe.subscriptions.list({
|
|
668
|
+
customer: customerId,
|
|
669
|
+
status: 'all',
|
|
670
|
+
limit: 10
|
|
671
|
+
});
|
|
672
|
+
if (!Array.isArray(subscriptions?.data))
|
|
673
|
+
continue;
|
|
674
|
+
for (const subscription of subscriptions.data) {
|
|
675
|
+
if (!isStripeManagedSubscriptionStatus(subscription?.status))
|
|
676
|
+
continue;
|
|
677
|
+
const subscriptionId = String(subscription?.id || '').trim();
|
|
678
|
+
if (!subscriptionId || seenSubscriptionIds.has(subscriptionId))
|
|
679
|
+
continue;
|
|
680
|
+
seenSubscriptionIds.add(subscriptionId);
|
|
681
|
+
const metadataUserId = String(subscription?.metadata?.userId || subscription?.metadata?.user_id || '').trim();
|
|
682
|
+
activeSubscriptions.push({
|
|
683
|
+
customerId,
|
|
684
|
+
subscription,
|
|
685
|
+
metadataUserMatch: Boolean(metadataUserId) && metadataUserId === input.userId
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
activeSubscriptions.sort((left, right) => {
|
|
690
|
+
if (left.metadataUserMatch !== right.metadataUserMatch)
|
|
691
|
+
return left.metadataUserMatch ? -1 : 1;
|
|
692
|
+
if (left.customerId === input.currentCustomerId && right.customerId !== input.currentCustomerId)
|
|
693
|
+
return -1;
|
|
694
|
+
if (right.customerId === input.currentCustomerId && left.customerId !== input.currentCustomerId)
|
|
695
|
+
return 1;
|
|
696
|
+
return String(left.subscription.id || '').localeCompare(String(right.subscription.id || ''));
|
|
697
|
+
});
|
|
698
|
+
return {
|
|
699
|
+
preferredCustomerId: String(input.currentCustomerId || '').trim()
|
|
700
|
+
|| customerIds[0]
|
|
701
|
+
|| activeSubscriptions[0]?.customerId
|
|
702
|
+
|| null,
|
|
703
|
+
activeSubscriptions
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
async function reconcileBillingMirrorIfNeeded(core, userIdRaw) {
|
|
707
|
+
const userId = String(userIdRaw || '').trim();
|
|
708
|
+
if (!userId)
|
|
709
|
+
return { row: null, conflictCode: null };
|
|
710
|
+
let row = getBillingMirrorRow(core, userId);
|
|
711
|
+
if (String(row?.stripe_subscription_id || '').trim()) {
|
|
712
|
+
try {
|
|
713
|
+
await reconcileBillingFromStripe(core, userId, row?.stripe_subscription_id);
|
|
714
|
+
row = getBillingMirrorRow(core, userId);
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
// Fall back to stored billing state when Stripe refresh is unavailable.
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const userIdentity = core.resolveUserIdentity(userId);
|
|
721
|
+
const discovery = await discoverStripeAccountStateForUser(core, {
|
|
722
|
+
userId,
|
|
723
|
+
normalizedEmail: userIdentity?.email,
|
|
724
|
+
currentCustomerId: row?.stripe_customer_id || null
|
|
725
|
+
});
|
|
726
|
+
if (discovery.activeSubscriptions.length > 1) {
|
|
727
|
+
return { row, conflictCode: 'BILLING_DUPLICATE_ACTIVE_SUBSCRIPTIONS' };
|
|
728
|
+
}
|
|
729
|
+
if (discovery.activeSubscriptions.length === 1) {
|
|
730
|
+
const active = discovery.activeSubscriptions[0];
|
|
731
|
+
const discoveredSubscriptionId = String(active.subscription?.id || '').trim();
|
|
732
|
+
const discoveredCustomerId = String(active.customerId || '').trim();
|
|
733
|
+
const currentSubscriptionId = String(row?.stripe_subscription_id || '').trim();
|
|
734
|
+
const currentCustomerId = String(row?.stripe_customer_id || '').trim();
|
|
735
|
+
if (!currentCustomerId || currentCustomerId !== discoveredCustomerId || !currentSubscriptionId || currentSubscriptionId !== discoveredSubscriptionId) {
|
|
736
|
+
upsertBillingMirror(core, {
|
|
737
|
+
userId,
|
|
738
|
+
stripeCustomerId: discoveredCustomerId || null,
|
|
739
|
+
stripeSubscriptionId: discoveredSubscriptionId || null
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
applySubscriptionSnapshot(core, {
|
|
743
|
+
userId,
|
|
744
|
+
subscription: active.subscription,
|
|
745
|
+
source: 'checkout-confirm'
|
|
746
|
+
});
|
|
747
|
+
row = getBillingMirrorRow(core, userId);
|
|
748
|
+
return { row, conflictCode: null };
|
|
749
|
+
}
|
|
750
|
+
if (!String(row?.stripe_customer_id || '').trim() && discovery.preferredCustomerId) {
|
|
751
|
+
upsertBillingMirror(core, {
|
|
752
|
+
userId,
|
|
753
|
+
stripeCustomerId: discovery.preferredCustomerId
|
|
754
|
+
});
|
|
755
|
+
row = getBillingMirrorRow(core, userId);
|
|
756
|
+
}
|
|
757
|
+
return { row, conflictCode: null };
|
|
758
|
+
}
|
|
759
|
+
function purgeExpiredWebhookFailures(core) {
|
|
760
|
+
const db = core.getDatabaseAdapter();
|
|
761
|
+
const tenantId = core.getTenantId();
|
|
762
|
+
const result = db.prepare(`
|
|
763
|
+
DELETE FROM stripe_webhook_failures
|
|
764
|
+
WHERE tenant_id = ?
|
|
765
|
+
AND purge_after < ?
|
|
766
|
+
`).run(tenantId, new Date().toISOString());
|
|
767
|
+
return Number(result?.changes || 0);
|
|
768
|
+
}
|
|
769
|
+
function recordWebhookFailure(core, input) {
|
|
770
|
+
try {
|
|
771
|
+
const db = core.getDatabaseAdapter();
|
|
772
|
+
const tenantId = core.getTenantId();
|
|
773
|
+
const now = new Date();
|
|
774
|
+
const purgeAfter = new Date(now.getTime() + BILLING_DIAGNOSTIC_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString();
|
|
775
|
+
const redacted = redactStripePayload(input.payload);
|
|
776
|
+
const excerpt = boundedPayloadExcerpt(redacted);
|
|
777
|
+
const id = `whf-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
778
|
+
db.prepare(`
|
|
779
|
+
INSERT INTO stripe_webhook_failures (id, tenant_id, event_id, event_type, reason, payload_excerpt, purge_after, created_at)
|
|
780
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
781
|
+
`).run(id, tenantId, input.eventId, input.eventType, input.reason, excerpt, purgeAfter, now.toISOString());
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
// diagnostics are best-effort only
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
function resolvePriceAndIntervalFromInvoice(invoice) {
|
|
788
|
+
const line = invoice.lines?.data?.find((entry) => !!entry?.price?.id || !!entry?.pricing?.price_details?.price);
|
|
789
|
+
const legacyPriceId = String(line?.price?.id || '').trim();
|
|
790
|
+
const modernPriceId = String(line?.pricing?.price_details?.price || '').trim();
|
|
791
|
+
const priceId = modernPriceId || legacyPriceId || null;
|
|
792
|
+
const recurringInterval = parseInterval(line?.price?.recurring?.interval || '');
|
|
793
|
+
return { priceId, interval: recurringInterval };
|
|
794
|
+
}
|
|
795
|
+
function lockWebhookEvent(core, event) {
|
|
796
|
+
const db = core.getDatabaseAdapter();
|
|
797
|
+
const tenantId = core.getTenantId();
|
|
798
|
+
const result = db.prepare(`
|
|
799
|
+
INSERT INTO stripe_webhook_events (event_id, tenant_id, event_type, processed_at)
|
|
800
|
+
VALUES (?, ?, ?, ?)
|
|
801
|
+
ON CONFLICT (event_id) DO NOTHING
|
|
802
|
+
`).run(event.id, tenantId, event.type, new Date().toISOString());
|
|
803
|
+
return Number(result?.changes || 0) > 0;
|
|
804
|
+
}
|
|
805
|
+
async function importStripePrices(core) {
|
|
806
|
+
const stripe = getStripeClient();
|
|
807
|
+
const db = core.getDatabaseAdapter();
|
|
808
|
+
const tenantId = core.getTenantId();
|
|
809
|
+
const now = new Date().toISOString();
|
|
810
|
+
const validPlanVersionIds = new Set(db.prepare(`
|
|
811
|
+
SELECT plan_version_id
|
|
812
|
+
FROM plan_versions
|
|
813
|
+
WHERE tenant_id = ?
|
|
814
|
+
`).all(tenantId).map((row) => String(row.plan_version_id || '')).filter(Boolean));
|
|
815
|
+
const importedPriceIds = new Set();
|
|
816
|
+
const activeStripePriceIds = new Set();
|
|
817
|
+
const summary = {
|
|
818
|
+
imported: 0,
|
|
819
|
+
skipped: 0,
|
|
820
|
+
deactivated: 0,
|
|
821
|
+
details: []
|
|
822
|
+
};
|
|
823
|
+
let startingAfter;
|
|
824
|
+
while (true) {
|
|
825
|
+
const page = await stripe.prices.list({
|
|
826
|
+
active: true,
|
|
827
|
+
limit: 100,
|
|
828
|
+
starting_after: startingAfter,
|
|
829
|
+
type: 'recurring'
|
|
830
|
+
});
|
|
831
|
+
for (const price of page.data) {
|
|
832
|
+
activeStripePriceIds.add(String(price.id || '').trim());
|
|
833
|
+
const interval = parseInterval(price.recurring?.interval || '');
|
|
834
|
+
const planVersionId = String(price.metadata?.[PRICE_METADATA_PLAN_VERSION] || '').trim();
|
|
835
|
+
if (!interval || !planVersionId || !validPlanVersionIds.has(planVersionId)) {
|
|
836
|
+
summary.skipped += 1;
|
|
837
|
+
summary.details.push({
|
|
838
|
+
priceId: price.id,
|
|
839
|
+
status: 'skipped',
|
|
840
|
+
reason: !planVersionId
|
|
841
|
+
? `Missing metadata ${PRICE_METADATA_PLAN_VERSION}`
|
|
842
|
+
: (!interval ? 'Unsupported interval' : 'Unknown plan_version_id')
|
|
843
|
+
});
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
db.prepare(`
|
|
847
|
+
INSERT INTO billing_price_mappings (
|
|
848
|
+
tenant_id, plan_version_id, billing_interval, stripe_price_id, active, unit_amount, currency, created_at, updated_at
|
|
849
|
+
) VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?)
|
|
850
|
+
ON CONFLICT (tenant_id, plan_version_id, billing_interval, stripe_price_id) DO UPDATE SET
|
|
851
|
+
active = 1,
|
|
852
|
+
unit_amount = excluded.unit_amount,
|
|
853
|
+
currency = excluded.currency,
|
|
854
|
+
updated_at = excluded.updated_at
|
|
855
|
+
`).run(tenantId, planVersionId, interval, price.id, price.unit_amount, price.currency, now, now);
|
|
856
|
+
importedPriceIds.add(price.id);
|
|
857
|
+
summary.imported += 1;
|
|
858
|
+
summary.details.push({ priceId: price.id, status: 'imported' });
|
|
859
|
+
}
|
|
860
|
+
if (!page.has_more || page.data.length === 0)
|
|
861
|
+
break;
|
|
862
|
+
startingAfter = page.data[page.data.length - 1]?.id;
|
|
863
|
+
if (!startingAfter)
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
const existing = db.prepare(`
|
|
867
|
+
SELECT stripe_price_id, pricing_type
|
|
868
|
+
FROM billing_price_mappings
|
|
869
|
+
WHERE tenant_id = ? AND active = 1
|
|
870
|
+
`).all(tenantId);
|
|
871
|
+
for (const row of existing) {
|
|
872
|
+
const priceId = String(row.stripe_price_id || '').trim();
|
|
873
|
+
const pricingType = String(row.pricing_type || '').trim().toLowerCase();
|
|
874
|
+
if (!priceId)
|
|
875
|
+
continue;
|
|
876
|
+
if (pricingType === 'free')
|
|
877
|
+
continue;
|
|
878
|
+
if (importedPriceIds.has(priceId))
|
|
879
|
+
continue;
|
|
880
|
+
if (activeStripePriceIds.has(priceId))
|
|
881
|
+
continue;
|
|
882
|
+
db.prepare(`
|
|
883
|
+
UPDATE billing_price_mappings
|
|
884
|
+
SET active = 0, updated_at = ?
|
|
885
|
+
WHERE tenant_id = ? AND stripe_price_id = ?
|
|
886
|
+
`).run(now, tenantId, priceId);
|
|
887
|
+
summary.deactivated += 1;
|
|
888
|
+
}
|
|
889
|
+
return summary;
|
|
890
|
+
}
|
|
891
|
+
export function registerBillingRoutes(deps) {
|
|
892
|
+
const { addRoute, core, context, ensureAuthenticatedUser, ensureAdminRole, requestUserId, resolveErrorStatusCode, auditAdminAction } = deps;
|
|
893
|
+
const billingEnabled = () => parseBoolean(process.env.TASKFORCE_BILLING_ENABLED, false);
|
|
894
|
+
const ensureCloudBilling = (req, res) => {
|
|
895
|
+
if (!billingEnabled()) {
|
|
896
|
+
billingDisabledResponse(res);
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
const runtime = context.authConfig?.runtimeMode || requestRuntimeMode(req);
|
|
900
|
+
if (runtime !== 'cloud') {
|
|
901
|
+
cloudOnlyResponse(res);
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
return true;
|
|
905
|
+
};
|
|
906
|
+
const ensureCloudPricingRoute = (req, res) => {
|
|
907
|
+
if (!billingEnabled()) {
|
|
908
|
+
billingDisabledResponse(res);
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
const runtime = context.authConfig?.runtimeMode || requestRuntimeMode(req);
|
|
912
|
+
if (runtime !== 'cloud') {
|
|
913
|
+
localPricingProxyRequiredResponse(res);
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
return true;
|
|
917
|
+
};
|
|
918
|
+
addRoute('GET', '/api/taskforce/billing/status', async (req, res) => {
|
|
919
|
+
if (!ensureCloudBilling(req, res))
|
|
920
|
+
return;
|
|
921
|
+
if (!ensureAuthenticatedUser(req)) {
|
|
922
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
923
|
+
res.end(JSON.stringify({ error: 'Authentication required.' }));
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
try {
|
|
927
|
+
const userId = requestUserId(req);
|
|
928
|
+
const db = core.getDatabaseAdapter();
|
|
929
|
+
const tenantId = core.getTenantId();
|
|
930
|
+
const billingReconciliation = await reconcileBillingMirrorIfNeeded(core, userId);
|
|
931
|
+
const row = billingReconciliation.row;
|
|
932
|
+
const access = core.getAccountAccessStatus(userId);
|
|
933
|
+
if (!access.hasEntitlement || !access.entitlement) {
|
|
934
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
935
|
+
res.end(JSON.stringify({
|
|
936
|
+
stripeStatus: row?.stripe_status || null,
|
|
937
|
+
stripeCustomerId: row?.stripe_customer_id || null,
|
|
938
|
+
stripeSubscriptionId: row?.stripe_subscription_id || null,
|
|
939
|
+
stripePriceId: row?.stripe_price_id || null,
|
|
940
|
+
billingInterval: row?.billing_interval || null,
|
|
941
|
+
trialEnd: row?.trial_end || null,
|
|
942
|
+
currentPeriodEnd: row?.current_period_end || null,
|
|
943
|
+
effectiveUntil: row?.effective_until || null,
|
|
944
|
+
entitlementState: null,
|
|
945
|
+
planVersionId: null,
|
|
946
|
+
planId: null,
|
|
947
|
+
planName: null,
|
|
948
|
+
gate: access.gate,
|
|
949
|
+
message: access.message,
|
|
950
|
+
planSelectionRequired: access.planSelectionRequired,
|
|
951
|
+
billingConflictCode: billingReconciliation.conflictCode
|
|
952
|
+
}));
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
const entitlement = access.entitlement;
|
|
956
|
+
const planRow = db.prepare(`
|
|
957
|
+
SELECT display_name
|
|
958
|
+
FROM plan_catalog
|
|
959
|
+
WHERE tenant_id = ? AND plan_id = ?
|
|
960
|
+
LIMIT 1
|
|
961
|
+
`).get(tenantId, entitlement.planId);
|
|
962
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
963
|
+
res.end(JSON.stringify({
|
|
964
|
+
stripeStatus: row?.stripe_status || null,
|
|
965
|
+
stripeCustomerId: row?.stripe_customer_id || null,
|
|
966
|
+
stripeSubscriptionId: row?.stripe_subscription_id || null,
|
|
967
|
+
stripePriceId: row?.stripe_price_id || null,
|
|
968
|
+
billingInterval: row?.billing_interval || null,
|
|
969
|
+
trialEnd: row?.trial_end || null,
|
|
970
|
+
currentPeriodEnd: row?.current_period_end || null,
|
|
971
|
+
effectiveUntil: row?.effective_until || null,
|
|
972
|
+
entitlementState: entitlement.state,
|
|
973
|
+
planVersionId: entitlement.planVersionId,
|
|
974
|
+
planId: entitlement.planId,
|
|
975
|
+
planName: planRow?.display_name ? String(planRow.display_name) : entitlement.planId,
|
|
976
|
+
gate: access.gate,
|
|
977
|
+
message: access.message,
|
|
978
|
+
planSelectionRequired: access.planSelectionRequired,
|
|
979
|
+
billingConflictCode: billingReconciliation.conflictCode
|
|
980
|
+
}));
|
|
981
|
+
}
|
|
982
|
+
catch (error) {
|
|
983
|
+
const statusCode = resolveErrorStatusCode(error, 400);
|
|
984
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
985
|
+
res.end(JSON.stringify({ error: String(error?.message || 'Unable to read billing status'), code: error?.code }));
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
addRoute('GET', '/api/taskforce/admin/billing/config', async (req, res) => {
|
|
989
|
+
if (!ensureCloudBilling(req, res))
|
|
990
|
+
return;
|
|
991
|
+
if (!ensureAdminRole(req)) {
|
|
992
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
993
|
+
res.end(JSON.stringify({ error: 'Forbidden: system admin role required.' }));
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const config = readBillingConfig(core);
|
|
997
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
998
|
+
res.end(JSON.stringify(config));
|
|
999
|
+
});
|
|
1000
|
+
addRoute('PUT', '/api/taskforce/admin/billing/config', async (req, res) => {
|
|
1001
|
+
if (!ensureCloudBilling(req, res))
|
|
1002
|
+
return;
|
|
1003
|
+
if (!ensureAdminRole(req)) {
|
|
1004
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1005
|
+
res.end(JSON.stringify({ error: 'Forbidden: system admin role required.' }));
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
const body = await parseJsonBody(req);
|
|
1009
|
+
const nextConfig = {
|
|
1010
|
+
tiers: body?.tiers && typeof body.tiers === 'object' && !Array.isArray(body.tiers) ? body.tiers : {},
|
|
1011
|
+
taxEnabled: parseBoolean(body?.tax_enabled, false),
|
|
1012
|
+
billingAddress: String(body?.billing_address || '').trim().toLowerCase() === 'required' ? 'required' : 'auto'
|
|
1013
|
+
};
|
|
1014
|
+
writeBillingConfig(core, nextConfig);
|
|
1015
|
+
auditAdminAction(req, 'billing-config-updated', { billingAddress: nextConfig.billingAddress });
|
|
1016
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1017
|
+
res.end(JSON.stringify(nextConfig));
|
|
1018
|
+
});
|
|
1019
|
+
addRoute('GET', '/api/taskforce/admin/billing/prices', async (req, res) => {
|
|
1020
|
+
if (!ensureCloudBilling(req, res))
|
|
1021
|
+
return;
|
|
1022
|
+
if (!ensureAdminRole(req)) {
|
|
1023
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1024
|
+
res.end(JSON.stringify({ error: 'Forbidden: system admin role required.' }));
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
const db = core.getDatabaseAdapter();
|
|
1028
|
+
const tenantId = core.getTenantId();
|
|
1029
|
+
const rows = db.prepare(`
|
|
1030
|
+
SELECT plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
|
|
1031
|
+
FROM billing_price_mappings
|
|
1032
|
+
WHERE tenant_id = ?
|
|
1033
|
+
ORDER BY plan_version_id ASC, billing_interval ASC, stripe_price_id ASC
|
|
1034
|
+
`).all(tenantId);
|
|
1035
|
+
if (rows.length === 0) {
|
|
1036
|
+
console.warn(`[Taskforce] Cloud billing returned no price mappings for tenant=${tenantId}.`);
|
|
1037
|
+
}
|
|
1038
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1039
|
+
res.end(JSON.stringify({ prices: rows }));
|
|
1040
|
+
});
|
|
1041
|
+
addRoute('GET', '/api/taskforce/admin/billing/stripe-price-options', async (req, res) => {
|
|
1042
|
+
if (!ensureCloudBilling(req, res))
|
|
1043
|
+
return;
|
|
1044
|
+
if (!ensureAdminRole(req)) {
|
|
1045
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1046
|
+
res.end(JSON.stringify({ error: 'Forbidden: system admin role required.' }));
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
try {
|
|
1050
|
+
const requestUrl = parseRequestUrl(req);
|
|
1051
|
+
const interval = parseInterval(requestUrl.searchParams.get('interval'));
|
|
1052
|
+
const options = await listStripePriceOptions(interval);
|
|
1053
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1054
|
+
res.end(JSON.stringify({ options }));
|
|
1055
|
+
}
|
|
1056
|
+
catch (error) {
|
|
1057
|
+
const statusCode = resolveErrorStatusCode(error, 400);
|
|
1058
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
1059
|
+
res.end(JSON.stringify({ error: String(error?.message || 'Unable to list Stripe prices'), code: error?.code }));
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
addRoute('PUT', '/api/taskforce/admin/billing/price-mappings', async (req, res) => {
|
|
1063
|
+
if (!ensureCloudBilling(req, res))
|
|
1064
|
+
return;
|
|
1065
|
+
if (!ensureAdminRole(req)) {
|
|
1066
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1067
|
+
res.end(JSON.stringify({ error: 'Forbidden: system admin role required.' }));
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const body = await parseJsonBody(req);
|
|
1071
|
+
const planVersionId = String(body?.planVersionId || '').trim();
|
|
1072
|
+
const interval = parseInterval(body?.interval);
|
|
1073
|
+
const pricingType = parsePricingType(body?.pricingType) || 'stripe';
|
|
1074
|
+
const stripePriceId = String(body?.stripePriceId || '').trim();
|
|
1075
|
+
const setActive = body?.active === false ? false : true;
|
|
1076
|
+
if (!planVersionId || !interval) {
|
|
1077
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1078
|
+
res.end(JSON.stringify({ error: 'planVersionId and interval are required.', code: 'BILLING_MAPPING_INPUT_REQUIRED' }));
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
if (pricingType === 'stripe' && !stripePriceId) {
|
|
1082
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1083
|
+
res.end(JSON.stringify({ error: 'stripePriceId is required for Stripe pricing.', code: 'BILLING_MAPPING_STRIPE_PRICE_REQUIRED' }));
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const db = core.getDatabaseAdapter();
|
|
1087
|
+
const tenantId = core.getTenantId();
|
|
1088
|
+
const versionExists = db.prepare(`
|
|
1089
|
+
SELECT plan_version_id
|
|
1090
|
+
FROM plan_versions
|
|
1091
|
+
WHERE tenant_id = ? AND plan_version_id = ?
|
|
1092
|
+
LIMIT 1
|
|
1093
|
+
`).get(tenantId, planVersionId);
|
|
1094
|
+
if (!versionExists?.plan_version_id) {
|
|
1095
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1096
|
+
res.end(JSON.stringify({ error: 'Plan version not found.', code: 'PLAN_VERSION_NOT_FOUND' }));
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
try {
|
|
1100
|
+
const now = new Date().toISOString();
|
|
1101
|
+
const targetStripePriceId = pricingType === 'free'
|
|
1102
|
+
? buildFreePricingSyntheticId(planVersionId, interval)
|
|
1103
|
+
: stripePriceId;
|
|
1104
|
+
let unitAmount = 0;
|
|
1105
|
+
let currency = null;
|
|
1106
|
+
if (pricingType === 'stripe') {
|
|
1107
|
+
const stripe = getStripeClient();
|
|
1108
|
+
const stripePrice = await stripe.prices.retrieve(stripePriceId);
|
|
1109
|
+
const stripeInterval = parseInterval(stripePrice.recurring?.interval || '');
|
|
1110
|
+
if (!stripePrice.active) {
|
|
1111
|
+
res.writeHead(422, { 'Content-Type': 'application/json' });
|
|
1112
|
+
res.end(JSON.stringify({ error: 'Stripe price is not active.', code: 'STRIPE_PRICE_INACTIVE' }));
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
if (!stripeInterval) {
|
|
1116
|
+
res.writeHead(422, { 'Content-Type': 'application/json' });
|
|
1117
|
+
res.end(JSON.stringify({ error: 'Stripe price must be recurring month/year.', code: 'STRIPE_PRICE_INTERVAL_UNSUPPORTED' }));
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
if (stripeInterval !== interval) {
|
|
1121
|
+
res.writeHead(422, { 'Content-Type': 'application/json' });
|
|
1122
|
+
res.end(JSON.stringify({ error: `Stripe price interval (${stripeInterval}) does not match requested interval (${interval}).`, code: 'STRIPE_PRICE_INTERVAL_MISMATCH' }));
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
unitAmount = typeof stripePrice.unit_amount === 'number' ? stripePrice.unit_amount : null;
|
|
1126
|
+
currency = stripePrice.currency || null;
|
|
1127
|
+
}
|
|
1128
|
+
const beforeRows = db.prepare(`
|
|
1129
|
+
SELECT plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency
|
|
1130
|
+
FROM billing_price_mappings
|
|
1131
|
+
WHERE tenant_id = ? AND plan_version_id = ? AND billing_interval = ?
|
|
1132
|
+
ORDER BY stripe_price_id ASC
|
|
1133
|
+
`).all(tenantId, planVersionId, interval);
|
|
1134
|
+
if (setActive) {
|
|
1135
|
+
db.prepare(`
|
|
1136
|
+
UPDATE billing_price_mappings
|
|
1137
|
+
SET active = 0, updated_at = ?
|
|
1138
|
+
WHERE tenant_id = ?
|
|
1139
|
+
AND plan_version_id = ?
|
|
1140
|
+
AND billing_interval = ?
|
|
1141
|
+
AND stripe_price_id <> ?
|
|
1142
|
+
AND active = 1
|
|
1143
|
+
`).run(now, tenantId, planVersionId, interval, targetStripePriceId);
|
|
1144
|
+
}
|
|
1145
|
+
db.prepare(`
|
|
1146
|
+
INSERT INTO billing_price_mappings (
|
|
1147
|
+
tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
|
|
1148
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1149
|
+
ON CONFLICT (tenant_id, plan_version_id, billing_interval, stripe_price_id) DO UPDATE SET
|
|
1150
|
+
pricing_type = excluded.pricing_type,
|
|
1151
|
+
active = excluded.active,
|
|
1152
|
+
unit_amount = excluded.unit_amount,
|
|
1153
|
+
currency = excluded.currency,
|
|
1154
|
+
updated_at = excluded.updated_at
|
|
1155
|
+
`).run(tenantId, planVersionId, interval, targetStripePriceId, pricingType, setActive ? 1 : 0, unitAmount, currency, now, now);
|
|
1156
|
+
const mapping = db.prepare(`
|
|
1157
|
+
SELECT plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
|
|
1158
|
+
FROM billing_price_mappings
|
|
1159
|
+
WHERE tenant_id = ? AND plan_version_id = ? AND billing_interval = ? AND stripe_price_id = ?
|
|
1160
|
+
LIMIT 1
|
|
1161
|
+
`).get(tenantId, planVersionId, interval, targetStripePriceId);
|
|
1162
|
+
const afterRows = db.prepare(`
|
|
1163
|
+
SELECT plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency
|
|
1164
|
+
FROM billing_price_mappings
|
|
1165
|
+
WHERE tenant_id = ? AND plan_version_id = ? AND billing_interval = ?
|
|
1166
|
+
ORDER BY stripe_price_id ASC
|
|
1167
|
+
`).all(tenantId, planVersionId, interval);
|
|
1168
|
+
auditAdminAction(req, 'billing-price-mapping-upsert', {
|
|
1169
|
+
planVersionId,
|
|
1170
|
+
interval,
|
|
1171
|
+
pricingType,
|
|
1172
|
+
stripePriceId: pricingType === 'free' ? null : stripePriceId,
|
|
1173
|
+
active: setActive,
|
|
1174
|
+
before: beforeRows,
|
|
1175
|
+
after: afterRows
|
|
1176
|
+
});
|
|
1177
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1178
|
+
res.end(JSON.stringify({ mapping }));
|
|
1179
|
+
}
|
|
1180
|
+
catch (error) {
|
|
1181
|
+
const statusCode = resolveErrorStatusCode(error, 400);
|
|
1182
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
1183
|
+
res.end(JSON.stringify({ error: String(error?.message || 'Unable to upsert price mapping'), code: error?.code }));
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
addRoute('POST', '/api/taskforce/admin/billing/price-mappings/deactivate', async (req, res) => {
|
|
1187
|
+
if (!ensureCloudBilling(req, res))
|
|
1188
|
+
return;
|
|
1189
|
+
if (!ensureAdminRole(req)) {
|
|
1190
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1191
|
+
res.end(JSON.stringify({ error: 'Forbidden: system admin role required.' }));
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
const body = await parseJsonBody(req);
|
|
1195
|
+
const planVersionId = String(body?.planVersionId || '').trim();
|
|
1196
|
+
const interval = parseInterval(body?.interval);
|
|
1197
|
+
const stripePriceId = String(body?.stripePriceId || '').trim();
|
|
1198
|
+
if (!planVersionId || !interval) {
|
|
1199
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1200
|
+
res.end(JSON.stringify({ error: 'planVersionId and interval are required.', code: 'BILLING_MAPPING_TARGET_REQUIRED' }));
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
const db = core.getDatabaseAdapter();
|
|
1204
|
+
const tenantId = core.getTenantId();
|
|
1205
|
+
const now = new Date().toISOString();
|
|
1206
|
+
const beforeRows = db.prepare(`
|
|
1207
|
+
SELECT plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency
|
|
1208
|
+
FROM billing_price_mappings
|
|
1209
|
+
WHERE tenant_id = ? AND plan_version_id = ? AND billing_interval = ?
|
|
1210
|
+
ORDER BY stripe_price_id ASC
|
|
1211
|
+
`).all(tenantId, planVersionId, interval);
|
|
1212
|
+
const result = stripePriceId
|
|
1213
|
+
? db.prepare(`
|
|
1214
|
+
UPDATE billing_price_mappings
|
|
1215
|
+
SET active = 0, updated_at = ?
|
|
1216
|
+
WHERE tenant_id = ? AND plan_version_id = ? AND billing_interval = ? AND stripe_price_id = ?
|
|
1217
|
+
`).run(now, tenantId, planVersionId, interval, stripePriceId)
|
|
1218
|
+
: db.prepare(`
|
|
1219
|
+
UPDATE billing_price_mappings
|
|
1220
|
+
SET active = 0, updated_at = ?
|
|
1221
|
+
WHERE tenant_id = ? AND plan_version_id = ? AND billing_interval = ?
|
|
1222
|
+
`).run(now, tenantId, planVersionId, interval);
|
|
1223
|
+
const afterRows = db.prepare(`
|
|
1224
|
+
SELECT plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency
|
|
1225
|
+
FROM billing_price_mappings
|
|
1226
|
+
WHERE tenant_id = ? AND plan_version_id = ? AND billing_interval = ?
|
|
1227
|
+
ORDER BY stripe_price_id ASC
|
|
1228
|
+
`).all(tenantId, planVersionId, interval);
|
|
1229
|
+
auditAdminAction(req, 'billing-price-mapping-deactivate', {
|
|
1230
|
+
planVersionId,
|
|
1231
|
+
interval,
|
|
1232
|
+
stripePriceId: stripePriceId || null,
|
|
1233
|
+
changedRows: Number(result?.changes || 0),
|
|
1234
|
+
before: beforeRows,
|
|
1235
|
+
after: afterRows
|
|
1236
|
+
});
|
|
1237
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1238
|
+
res.end(JSON.stringify({ success: true, changedRows: Number(result?.changes || 0) }));
|
|
1239
|
+
});
|
|
1240
|
+
addRoute('GET', '/api/taskforce/admin/billing/webhook-failures', async (req, res) => {
|
|
1241
|
+
if (!ensureCloudBilling(req, res))
|
|
1242
|
+
return;
|
|
1243
|
+
if (!ensureAdminRole(req)) {
|
|
1244
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1245
|
+
res.end(JSON.stringify({ error: 'Forbidden: system admin role required.' }));
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
const db = core.getDatabaseAdapter();
|
|
1249
|
+
const tenantId = core.getTenantId();
|
|
1250
|
+
const queryLimit = req.url
|
|
1251
|
+
? new URL(req.url, 'https://taskforce.local').searchParams.get('limit')
|
|
1252
|
+
: null;
|
|
1253
|
+
const limit = parsePositiveIntBounded(queryLimit, 50, 1, 200);
|
|
1254
|
+
const now = new Date().toISOString();
|
|
1255
|
+
const purged = purgeExpiredWebhookFailures(core);
|
|
1256
|
+
const rows = db.prepare(`
|
|
1257
|
+
SELECT id, event_id, event_type, reason, payload_excerpt, created_at, purge_after
|
|
1258
|
+
FROM stripe_webhook_failures
|
|
1259
|
+
WHERE tenant_id = ?
|
|
1260
|
+
ORDER BY created_at DESC
|
|
1261
|
+
LIMIT ?
|
|
1262
|
+
`).all(tenantId, limit);
|
|
1263
|
+
const total = db.prepare(`
|
|
1264
|
+
SELECT COUNT(*) AS count
|
|
1265
|
+
FROM stripe_webhook_failures
|
|
1266
|
+
WHERE tenant_id = ?
|
|
1267
|
+
`).get(tenantId);
|
|
1268
|
+
const expiringSoon = db.prepare(`
|
|
1269
|
+
SELECT COUNT(*) AS count
|
|
1270
|
+
FROM stripe_webhook_failures
|
|
1271
|
+
WHERE tenant_id = ?
|
|
1272
|
+
AND purge_after <= ?
|
|
1273
|
+
`).get(tenantId, new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString());
|
|
1274
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1275
|
+
res.end(JSON.stringify({
|
|
1276
|
+
asOf: now,
|
|
1277
|
+
purgedExpiredRows: purged,
|
|
1278
|
+
total: Number(total?.count || 0),
|
|
1279
|
+
expiringWithin24h: Number(expiringSoon?.count || 0),
|
|
1280
|
+
rows
|
|
1281
|
+
}));
|
|
1282
|
+
});
|
|
1283
|
+
addRoute('POST', '/api/taskforce/admin/billing/prices/import', async (req, res) => {
|
|
1284
|
+
if (!ensureCloudBilling(req, res))
|
|
1285
|
+
return;
|
|
1286
|
+
if (!ensureAdminRole(req)) {
|
|
1287
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1288
|
+
res.end(JSON.stringify({ error: 'Forbidden: system admin role required.' }));
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
try {
|
|
1292
|
+
const summary = await importStripePrices(core);
|
|
1293
|
+
auditAdminAction(req, 'billing-prices-import', {
|
|
1294
|
+
imported: summary.imported,
|
|
1295
|
+
skipped: summary.skipped,
|
|
1296
|
+
deactivated: summary.deactivated
|
|
1297
|
+
});
|
|
1298
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1299
|
+
res.end(JSON.stringify(summary));
|
|
1300
|
+
}
|
|
1301
|
+
catch (error) {
|
|
1302
|
+
const statusCode = resolveErrorStatusCode(error, 400);
|
|
1303
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
1304
|
+
res.end(JSON.stringify({ error: String(error?.message || 'Unable to import Stripe prices'), code: error?.code }));
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
addRoute('GET', '/__taskforce/mock-stripe/checkout', async (req, res) => {
|
|
1308
|
+
if (!billingEnabled() || (context.authConfig?.runtimeMode || requestRuntimeMode(req)) !== 'cloud' || !isMockStripeEnabled()) {
|
|
1309
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1310
|
+
res.end('Not found');
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
const sessionId = parseRequestUrl(req).searchParams.get('session_id') || '';
|
|
1314
|
+
const session = readMockStripeCheckoutSession(sessionId);
|
|
1315
|
+
if (!session) {
|
|
1316
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1317
|
+
res.end('Mock checkout session not found.');
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1321
|
+
res.end(renderMockStripeCheckoutHtml({
|
|
1322
|
+
sessionId: session.id,
|
|
1323
|
+
planVersionId: session.subscriptionMetadata.planVersionId || session.subscriptionMetadata.plan_version_id || null,
|
|
1324
|
+
interval: session.interval
|
|
1325
|
+
}));
|
|
1326
|
+
});
|
|
1327
|
+
addRoute('GET', '/__taskforce/mock-stripe/checkout/complete', async (req, res) => {
|
|
1328
|
+
if (!billingEnabled() || (context.authConfig?.runtimeMode || requestRuntimeMode(req)) !== 'cloud' || !isMockStripeEnabled()) {
|
|
1329
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1330
|
+
res.end('Not found');
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
const sessionId = parseRequestUrl(req).searchParams.get('session_id') || '';
|
|
1334
|
+
try {
|
|
1335
|
+
const session = completeMockStripeCheckoutSession(sessionId);
|
|
1336
|
+
const nextUrl = String(session.successUrl || '').replace('{CHECKOUT_SESSION_ID}', encodeURIComponent(session.id));
|
|
1337
|
+
res.writeHead(302, { Location: nextUrl || '/' });
|
|
1338
|
+
res.end();
|
|
1339
|
+
}
|
|
1340
|
+
catch (error) {
|
|
1341
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1342
|
+
res.end(String(error?.message || 'Mock checkout session not found.'));
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
addRoute('GET', '/__taskforce/mock-stripe/checkout/cancel', async (req, res) => {
|
|
1346
|
+
if (!billingEnabled() || (context.authConfig?.runtimeMode || requestRuntimeMode(req)) !== 'cloud' || !isMockStripeEnabled()) {
|
|
1347
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1348
|
+
res.end('Not found');
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
const sessionId = parseRequestUrl(req).searchParams.get('session_id') || '';
|
|
1352
|
+
const session = readMockStripeCheckoutSession(sessionId);
|
|
1353
|
+
res.writeHead(302, {
|
|
1354
|
+
Location: String(session?.cancelUrl || '/')
|
|
1355
|
+
});
|
|
1356
|
+
res.end();
|
|
1357
|
+
});
|
|
1358
|
+
addRoute('POST', '/api/taskforce/billing/checkout-session', async (req, res) => {
|
|
1359
|
+
if (!ensureCloudBilling(req, res))
|
|
1360
|
+
return;
|
|
1361
|
+
if (!ensureAuthenticatedUser(req)) {
|
|
1362
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1363
|
+
res.end(JSON.stringify({ error: 'Authentication required.' }));
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
const body = await parseJsonBody(req);
|
|
1367
|
+
const interval = parseInterval(body?.interval);
|
|
1368
|
+
const planVersionId = String(body?.planVersionId || '').trim();
|
|
1369
|
+
if (!interval || !planVersionId) {
|
|
1370
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1371
|
+
res.end(JSON.stringify({ error: 'planVersionId and interval are required.' }));
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
const pricing = resolveActivePricingForPlanVersion(core, planVersionId, interval);
|
|
1375
|
+
if (!pricing) {
|
|
1376
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1377
|
+
res.end(JSON.stringify({ error: 'No active pricing mapping found for requested plan/interval.' }));
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
try {
|
|
1381
|
+
const userId = requestUserId(req);
|
|
1382
|
+
const currentBillingResult = await reconcileBillingMirrorIfNeeded(core, userId);
|
|
1383
|
+
const currentBilling = currentBillingResult.row;
|
|
1384
|
+
if (currentBillingResult.conflictCode === 'BILLING_DUPLICATE_ACTIVE_SUBSCRIPTIONS') {
|
|
1385
|
+
console.warn(`[Taskforce Billing] Duplicate active subscriptions detected for user=${userId}`);
|
|
1386
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1387
|
+
res.end(JSON.stringify({
|
|
1388
|
+
error: 'Multiple active subscriptions are already linked to this account. Please contact support before making changes.',
|
|
1389
|
+
code: currentBillingResult.conflictCode
|
|
1390
|
+
}));
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
const currentEntitlement = getEntitlementRow(core, userId);
|
|
1394
|
+
const userIdentity = core.resolveUserIdentity(userId);
|
|
1395
|
+
const normalizedUserEmail = normalizeEmail(userIdentity?.email);
|
|
1396
|
+
if (pricing.pricingType === 'free') {
|
|
1397
|
+
if (currentEntitlement?.plan_version_id && String(currentEntitlement.plan_version_id).trim() === planVersionId) {
|
|
1398
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1399
|
+
res.end(JSON.stringify({
|
|
1400
|
+
checkoutState: 'already_current_plan',
|
|
1401
|
+
planVersionId,
|
|
1402
|
+
message: 'You are already on this plan.'
|
|
1403
|
+
}));
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
const billingProfile = core.getBillingMirrorProfileForPlanVersion(planVersionId);
|
|
1407
|
+
core.createUserBillingRecord(userId, billingProfile);
|
|
1408
|
+
core.updateAccountEntitlement({
|
|
1409
|
+
accountId: userId,
|
|
1410
|
+
planVersionId,
|
|
1411
|
+
entitlementState: 'active',
|
|
1412
|
+
actorUserId: userId,
|
|
1413
|
+
actorRole: 'user',
|
|
1414
|
+
reason: 'free-plan-selection'
|
|
1415
|
+
});
|
|
1416
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1417
|
+
res.end(JSON.stringify({ checkoutState: 'free', planVersionId }));
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
const priceId = pricing.stripePriceId;
|
|
1421
|
+
if (!priceId) {
|
|
1422
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1423
|
+
res.end(JSON.stringify({ error: 'No active Stripe price mapping found for requested plan/interval.' }));
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
const stripe = getStripeClient();
|
|
1427
|
+
let currentSubscriptionId = String(currentBilling?.stripe_subscription_id || '').trim();
|
|
1428
|
+
const currentCustomerId = String(currentBilling?.stripe_customer_id || '').trim();
|
|
1429
|
+
const currentPlanVersionId = String(currentEntitlement?.plan_version_id || '').trim();
|
|
1430
|
+
const currentInterval = parseInterval(String(currentBilling?.billing_interval || '').trim());
|
|
1431
|
+
const stripeAccountDiscovery = await discoverStripeAccountStateForUser(core, {
|
|
1432
|
+
userId,
|
|
1433
|
+
normalizedEmail: normalizedUserEmail,
|
|
1434
|
+
currentCustomerId
|
|
1435
|
+
});
|
|
1436
|
+
if (stripeAccountDiscovery.activeSubscriptions.length > 1) {
|
|
1437
|
+
console.warn(`[Taskforce Billing] Duplicate active subscriptions detected for user=${userId}`);
|
|
1438
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1439
|
+
res.end(JSON.stringify({
|
|
1440
|
+
error: 'Multiple active subscriptions are already linked to this account. Please contact support before making changes.',
|
|
1441
|
+
code: 'BILLING_DUPLICATE_ACTIVE_SUBSCRIPTIONS'
|
|
1442
|
+
}));
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
let resolvedCustomerId = currentCustomerId || String(stripeAccountDiscovery.preferredCustomerId || '').trim();
|
|
1446
|
+
if (!currentSubscriptionId && stripeAccountDiscovery.activeSubscriptions.length === 1) {
|
|
1447
|
+
const activeSubscription = stripeAccountDiscovery.activeSubscriptions[0];
|
|
1448
|
+
currentSubscriptionId = String(activeSubscription.subscription?.id || '').trim();
|
|
1449
|
+
resolvedCustomerId = String(activeSubscription.customerId || '').trim() || resolvedCustomerId;
|
|
1450
|
+
if (currentSubscriptionId || resolvedCustomerId) {
|
|
1451
|
+
upsertBillingMirror(core, {
|
|
1452
|
+
userId,
|
|
1453
|
+
stripeCustomerId: resolvedCustomerId || null,
|
|
1454
|
+
stripeSubscriptionId: currentSubscriptionId || null
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
else if (!currentCustomerId && resolvedCustomerId) {
|
|
1459
|
+
upsertBillingMirror(core, {
|
|
1460
|
+
userId,
|
|
1461
|
+
stripeCustomerId: resolvedCustomerId
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
if (currentSubscriptionId && currentPlanVersionId === planVersionId && currentInterval === interval) {
|
|
1465
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1466
|
+
res.end(JSON.stringify({
|
|
1467
|
+
checkoutState: 'already_current_plan',
|
|
1468
|
+
planVersionId,
|
|
1469
|
+
message: 'You are already on this plan.'
|
|
1470
|
+
}));
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
if (currentSubscriptionId) {
|
|
1474
|
+
const current = await stripe.subscriptions.retrieve(currentSubscriptionId);
|
|
1475
|
+
const itemId = current.items.data[0]?.id;
|
|
1476
|
+
if (!itemId) {
|
|
1477
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1478
|
+
res.end(JSON.stringify({ error: 'Subscription has no updatable line item.' }));
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
const updated = await stripe.subscriptions.update(currentSubscriptionId, {
|
|
1482
|
+
items: [{ id: itemId, price: priceId }],
|
|
1483
|
+
proration_behavior: 'create_prorations',
|
|
1484
|
+
metadata: {
|
|
1485
|
+
userId,
|
|
1486
|
+
tenantId: core.getTenantId(),
|
|
1487
|
+
planVersionId,
|
|
1488
|
+
billingInterval: interval
|
|
1489
|
+
}
|
|
1490
|
+
});
|
|
1491
|
+
applySubscriptionSnapshot(core, {
|
|
1492
|
+
userId,
|
|
1493
|
+
subscription: updated,
|
|
1494
|
+
source: 'checkout-confirm'
|
|
1495
|
+
});
|
|
1496
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1497
|
+
res.end(JSON.stringify({
|
|
1498
|
+
checkoutState: 'updated',
|
|
1499
|
+
planVersionId,
|
|
1500
|
+
message: 'Your subscription was updated successfully.'
|
|
1501
|
+
}));
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
const baseUrl = resolveBaseUrl(req);
|
|
1505
|
+
const config = readBillingConfig(core);
|
|
1506
|
+
const planId = resolvePlanIdForVersion(core, planVersionId);
|
|
1507
|
+
const selectedVersion = planId
|
|
1508
|
+
? core.listPlanVersions(planId).find((version) => version.planVersionId === planVersionId) || null
|
|
1509
|
+
: null;
|
|
1510
|
+
const cancelParams = new URLSearchParams({
|
|
1511
|
+
screen: 'plans',
|
|
1512
|
+
checkout: 'cancel',
|
|
1513
|
+
planId: String(planId || ''),
|
|
1514
|
+
planVersionId,
|
|
1515
|
+
interval
|
|
1516
|
+
});
|
|
1517
|
+
const session = await stripe.checkout.sessions.create({
|
|
1518
|
+
mode: 'subscription',
|
|
1519
|
+
line_items: [{ price: priceId, quantity: 1 }],
|
|
1520
|
+
success_url: `${baseUrl}/?screen=plans&checkout=success&session_id={CHECKOUT_SESSION_ID}`,
|
|
1521
|
+
cancel_url: `${baseUrl}/?${cancelParams.toString()}`,
|
|
1522
|
+
...(resolvedCustomerId
|
|
1523
|
+
? { customer: resolvedCustomerId }
|
|
1524
|
+
: (normalizedUserEmail ? { customer_email: normalizedUserEmail } : {})),
|
|
1525
|
+
subscription_data: {
|
|
1526
|
+
...(selectedVersion && selectedVersion.trialDays > 0 ? { trial_period_days: selectedVersion.trialDays } : {}),
|
|
1527
|
+
metadata: {
|
|
1528
|
+
userId,
|
|
1529
|
+
tenantId: core.getTenantId(),
|
|
1530
|
+
planVersionId,
|
|
1531
|
+
billingInterval: interval
|
|
1532
|
+
}
|
|
1533
|
+
},
|
|
1534
|
+
billing_address_collection: config.billingAddress,
|
|
1535
|
+
automatic_tax: { enabled: config.taxEnabled },
|
|
1536
|
+
client_reference_id: userId,
|
|
1537
|
+
metadata: {
|
|
1538
|
+
userId,
|
|
1539
|
+
tenantId: core.getTenantId(),
|
|
1540
|
+
planVersionId,
|
|
1541
|
+
billingInterval: interval
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
const checkoutUrl = isMockStripeClient(stripe)
|
|
1545
|
+
? buildMockStripeHostedCheckoutUrl(resolveHostedServerBaseUrl(req), session.id)
|
|
1546
|
+
: (session.url || null);
|
|
1547
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1548
|
+
res.end(JSON.stringify({
|
|
1549
|
+
checkoutState: 'checkout',
|
|
1550
|
+
url: checkoutUrl,
|
|
1551
|
+
id: session.id,
|
|
1552
|
+
returnBaseUrl: baseUrl
|
|
1553
|
+
}));
|
|
1554
|
+
}
|
|
1555
|
+
catch (error) {
|
|
1556
|
+
if (isMissingStripeCustomerError(error)) {
|
|
1557
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1558
|
+
res.end(JSON.stringify({
|
|
1559
|
+
error: 'The saved Stripe customer for this account could not be found. Please contact support before retrying checkout.',
|
|
1560
|
+
code: 'BILLING_STRIPE_CUSTOMER_MISSING'
|
|
1561
|
+
}));
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
const statusCode = resolveErrorStatusCode(error, 400);
|
|
1565
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
1566
|
+
res.end(JSON.stringify({ error: String(error?.message || 'Unable to create checkout session'), code: error?.code }));
|
|
1567
|
+
}
|
|
1568
|
+
});
|
|
1569
|
+
addRoute('POST', '/api/taskforce/billing/checkout-session/confirm', async (req, res) => {
|
|
1570
|
+
if (!ensureCloudBilling(req, res))
|
|
1571
|
+
return;
|
|
1572
|
+
if (!ensureAuthenticatedUser(req)) {
|
|
1573
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1574
|
+
res.end(JSON.stringify({ error: 'Authentication required.' }));
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
const body = await parseJsonBody(req);
|
|
1578
|
+
const sessionId = String(body?.sessionId || '').trim();
|
|
1579
|
+
if (!sessionId) {
|
|
1580
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1581
|
+
res.end(JSON.stringify({ error: 'sessionId is required.' }));
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
try {
|
|
1585
|
+
const stripe = getStripeClient();
|
|
1586
|
+
const expectedUserId = requestUserId(req);
|
|
1587
|
+
const session = await stripe.checkout.sessions.retrieve(sessionId, {
|
|
1588
|
+
expand: ['subscription']
|
|
1589
|
+
});
|
|
1590
|
+
const metadata = session.metadata || {};
|
|
1591
|
+
const customerId = String(session.customer || '').trim();
|
|
1592
|
+
const subscriptionId = typeof session.subscription === 'string'
|
|
1593
|
+
? session.subscription.trim()
|
|
1594
|
+
: String(session.subscription?.id || '').trim();
|
|
1595
|
+
const resolvedUserId = String(metadata.userId || metadata.user_id || '').trim()
|
|
1596
|
+
|| resolveUserIdByCustomer(core, customerId)
|
|
1597
|
+
|| resolveUserIdBySubscription(core, subscriptionId);
|
|
1598
|
+
if (!resolvedUserId) {
|
|
1599
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1600
|
+
res.end(JSON.stringify({ error: 'Unable to resolve checkout session to an account.' }));
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
if (resolvedUserId !== expectedUserId) {
|
|
1604
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1605
|
+
res.end(JSON.stringify({ error: 'Checkout session does not belong to the current account.' }));
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
core.createUserBillingRecord(resolvedUserId);
|
|
1609
|
+
upsertBillingMirror(core, {
|
|
1610
|
+
userId: resolvedUserId,
|
|
1611
|
+
stripeCustomerId: customerId || null,
|
|
1612
|
+
stripeSubscriptionId: subscriptionId || null
|
|
1613
|
+
});
|
|
1614
|
+
let resolvedPlanVersionId = null;
|
|
1615
|
+
if (session.mode === 'subscription' && subscriptionId) {
|
|
1616
|
+
const subscription = typeof session.subscription === 'string' || !session.subscription
|
|
1617
|
+
? await stripe.subscriptions.retrieve(subscriptionId)
|
|
1618
|
+
: session.subscription;
|
|
1619
|
+
resolvedPlanVersionId = applySubscriptionSnapshot(core, {
|
|
1620
|
+
userId: resolvedUserId,
|
|
1621
|
+
subscription,
|
|
1622
|
+
source: 'checkout-confirm'
|
|
1623
|
+
}).planVersionId;
|
|
1624
|
+
}
|
|
1625
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1626
|
+
res.end(JSON.stringify({
|
|
1627
|
+
success: true,
|
|
1628
|
+
sessionId,
|
|
1629
|
+
planVersionId: resolvedPlanVersionId
|
|
1630
|
+
}));
|
|
1631
|
+
}
|
|
1632
|
+
catch (error) {
|
|
1633
|
+
const statusCode = resolveErrorStatusCode(error, 400);
|
|
1634
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
1635
|
+
res.end(JSON.stringify({ error: String(error?.message || 'Unable to confirm checkout session'), code: error?.code }));
|
|
1636
|
+
}
|
|
1637
|
+
});
|
|
1638
|
+
addRoute('POST', '/api/taskforce/billing/portal-session', async (req, res) => {
|
|
1639
|
+
if (!ensureCloudBilling(req, res))
|
|
1640
|
+
return;
|
|
1641
|
+
if (!ensureAuthenticatedUser(req)) {
|
|
1642
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1643
|
+
res.end(JSON.stringify({ error: 'Authentication required.' }));
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
const userId = requestUserId(req);
|
|
1647
|
+
const db = core.getDatabaseAdapter();
|
|
1648
|
+
const tenantId = core.getTenantId();
|
|
1649
|
+
const row = db.prepare(`
|
|
1650
|
+
SELECT stripe_customer_id
|
|
1651
|
+
FROM user_billing
|
|
1652
|
+
WHERE tenant_id = ? AND user_id = ?
|
|
1653
|
+
LIMIT 1
|
|
1654
|
+
`).get(tenantId, userId);
|
|
1655
|
+
let customerId = String(row?.stripe_customer_id || '').trim();
|
|
1656
|
+
if (!customerId) {
|
|
1657
|
+
const userIdentity = core.resolveUserIdentity(userId);
|
|
1658
|
+
const discovery = await discoverStripeAccountStateForUser(core, {
|
|
1659
|
+
userId,
|
|
1660
|
+
normalizedEmail: userIdentity?.email,
|
|
1661
|
+
currentCustomerId: null
|
|
1662
|
+
});
|
|
1663
|
+
if (discovery.activeSubscriptions.length > 1) {
|
|
1664
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1665
|
+
res.end(JSON.stringify({
|
|
1666
|
+
error: 'Multiple active subscriptions are already linked to this account. Please contact support before opening the billing portal.',
|
|
1667
|
+
code: 'BILLING_DUPLICATE_ACTIVE_SUBSCRIPTIONS'
|
|
1668
|
+
}));
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
customerId = String(discovery.preferredCustomerId || '').trim();
|
|
1672
|
+
if (customerId) {
|
|
1673
|
+
upsertBillingMirror(core, {
|
|
1674
|
+
userId,
|
|
1675
|
+
stripeCustomerId: customerId
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
if (!customerId) {
|
|
1680
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1681
|
+
res.end(JSON.stringify({ error: 'No Stripe customer is linked for this account.' }));
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
try {
|
|
1685
|
+
const stripe = getStripeClient();
|
|
1686
|
+
const baseUrl = resolveBaseUrl(req);
|
|
1687
|
+
const session = await stripe.billingPortal.sessions.create({
|
|
1688
|
+
customer: customerId,
|
|
1689
|
+
return_url: `${baseUrl}/settings`
|
|
1690
|
+
});
|
|
1691
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1692
|
+
res.end(JSON.stringify({ url: session.url, returnBaseUrl: baseUrl }));
|
|
1693
|
+
}
|
|
1694
|
+
catch (error) {
|
|
1695
|
+
const statusCode = resolveErrorStatusCode(error, 400);
|
|
1696
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
1697
|
+
res.end(JSON.stringify({ error: String(error?.message || 'Unable to create portal session'), code: error?.code }));
|
|
1698
|
+
}
|
|
1699
|
+
});
|
|
1700
|
+
addRoute('POST', '/api/taskforce/billing/subscription', async (req, res) => {
|
|
1701
|
+
if (!ensureCloudBilling(req, res))
|
|
1702
|
+
return;
|
|
1703
|
+
if (!ensureAuthenticatedUser(req)) {
|
|
1704
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1705
|
+
res.end(JSON.stringify({ error: 'Authentication required.' }));
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
const body = await parseJsonBody(req);
|
|
1709
|
+
const interval = parseInterval(body?.interval);
|
|
1710
|
+
const planVersionId = String(body?.planVersionId || '').trim();
|
|
1711
|
+
if (!interval || !planVersionId) {
|
|
1712
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1713
|
+
res.end(JSON.stringify({ error: 'planVersionId and interval are required.' }));
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
const nextPriceId = resolveStripePriceIdForPlanVersion(core, planVersionId, interval);
|
|
1717
|
+
if (!nextPriceId) {
|
|
1718
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1719
|
+
res.end(JSON.stringify({ error: 'No active Stripe price mapping found for requested plan/interval.' }));
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
const userId = requestUserId(req);
|
|
1723
|
+
const db = core.getDatabaseAdapter();
|
|
1724
|
+
const tenantId = core.getTenantId();
|
|
1725
|
+
const row = db.prepare(`
|
|
1726
|
+
SELECT stripe_subscription_id
|
|
1727
|
+
FROM user_billing
|
|
1728
|
+
WHERE tenant_id = ? AND user_id = ?
|
|
1729
|
+
LIMIT 1
|
|
1730
|
+
`).get(tenantId, userId);
|
|
1731
|
+
const subscriptionId = String(row?.stripe_subscription_id || '').trim();
|
|
1732
|
+
if (!subscriptionId) {
|
|
1733
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1734
|
+
res.end(JSON.stringify({ error: 'No Stripe subscription is linked for this account.' }));
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
try {
|
|
1738
|
+
const stripe = getStripeClient();
|
|
1739
|
+
const current = await stripe.subscriptions.retrieve(subscriptionId);
|
|
1740
|
+
const itemId = current.items.data[0]?.id;
|
|
1741
|
+
if (!itemId) {
|
|
1742
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1743
|
+
res.end(JSON.stringify({ error: 'Subscription has no updatable line item.' }));
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
const updated = await stripe.subscriptions.update(subscriptionId, {
|
|
1747
|
+
items: [{ id: itemId, price: nextPriceId }],
|
|
1748
|
+
proration_behavior: 'create_prorations',
|
|
1749
|
+
metadata: {
|
|
1750
|
+
planVersionId
|
|
1751
|
+
}
|
|
1752
|
+
});
|
|
1753
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1754
|
+
res.end(JSON.stringify({ subscription: updated }));
|
|
1755
|
+
}
|
|
1756
|
+
catch (error) {
|
|
1757
|
+
const statusCode = resolveErrorStatusCode(error, 400);
|
|
1758
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
1759
|
+
res.end(JSON.stringify({ error: String(error?.message || 'Unable to update subscription'), code: error?.code }));
|
|
1760
|
+
}
|
|
1761
|
+
});
|
|
1762
|
+
addRoute('POST', '/api/taskforce/billing/webhook', async (req, res) => {
|
|
1763
|
+
if (!ensureCloudBilling(req, res))
|
|
1764
|
+
return;
|
|
1765
|
+
const webhookSecret = String(process.env.TASKFORCE_STRIPE_WEBHOOK_SECRET || '').trim();
|
|
1766
|
+
if (!webhookSecret) {
|
|
1767
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1768
|
+
res.end(JSON.stringify({ error: 'TASKFORCE_STRIPE_WEBHOOK_SECRET is not configured.' }));
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
const signature = String(req.headers['stripe-signature'] || '').trim();
|
|
1772
|
+
if (!signature) {
|
|
1773
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1774
|
+
res.end(JSON.stringify({ error: 'Missing Stripe signature.' }));
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
let event;
|
|
1778
|
+
let rawBody;
|
|
1779
|
+
try {
|
|
1780
|
+
rawBody = await readRawBody(req);
|
|
1781
|
+
const stripe = getStripeClient();
|
|
1782
|
+
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
|
1783
|
+
}
|
|
1784
|
+
catch (error) {
|
|
1785
|
+
const code = Number(error?.statusCode || 400);
|
|
1786
|
+
res.writeHead(code >= 400 && code < 600 ? code : 400, { 'Content-Type': 'application/json' });
|
|
1787
|
+
res.end(JSON.stringify({ error: String(error?.message || 'Invalid webhook payload/signature') }));
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
try {
|
|
1791
|
+
const db = core.getDatabaseAdapter();
|
|
1792
|
+
const tenantId = core.getTenantId();
|
|
1793
|
+
purgeExpiredWebhookFailures(core);
|
|
1794
|
+
let duplicate = false;
|
|
1795
|
+
const tx = db.transaction(() => {
|
|
1796
|
+
const acquired = lockWebhookEvent(core, event);
|
|
1797
|
+
if (!acquired) {
|
|
1798
|
+
duplicate = true;
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
if (event.type === 'checkout.session.completed') {
|
|
1802
|
+
const session = event.data.object;
|
|
1803
|
+
const metadata = session.metadata || {};
|
|
1804
|
+
const customerId = String(session.customer || '').trim();
|
|
1805
|
+
const subscriptionId = String(session.subscription || '').trim();
|
|
1806
|
+
const userId = String(metadata.userId || metadata.user_id || '').trim()
|
|
1807
|
+
|| resolveUserIdByCustomer(core, customerId)
|
|
1808
|
+
|| resolveUserIdBySubscription(core, subscriptionId);
|
|
1809
|
+
if (!userId || !customerId) {
|
|
1810
|
+
throw new Error('Unable to link checkout completion to user/customer.');
|
|
1811
|
+
}
|
|
1812
|
+
core.createUserBillingRecord(userId);
|
|
1813
|
+
upsertBillingMirror(core, {
|
|
1814
|
+
userId,
|
|
1815
|
+
stripeCustomerId: customerId,
|
|
1816
|
+
stripeSubscriptionId: subscriptionId || null
|
|
1817
|
+
});
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
if (event.type === 'customer.subscription.created' || event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.deleted') {
|
|
1821
|
+
const subscription = event.data.object;
|
|
1822
|
+
const customerId = String(subscription.customer || '').trim();
|
|
1823
|
+
const userId = resolveUserIdByCustomer(core, customerId)
|
|
1824
|
+
|| String(subscription.metadata?.userId || subscription.metadata?.user_id || '').trim();
|
|
1825
|
+
if (!userId) {
|
|
1826
|
+
throw new Error('Unable to resolve user for subscription event.');
|
|
1827
|
+
}
|
|
1828
|
+
core.createUserBillingRecord(userId);
|
|
1829
|
+
const priceId = String(subscription.items?.data?.[0]?.price?.id || '').trim();
|
|
1830
|
+
const planVersionResolution = resolvePlanVersionIdForBillingEvent(core, [
|
|
1831
|
+
subscription.metadata?.planVersionId,
|
|
1832
|
+
subscription.metadata?.plan_version_id
|
|
1833
|
+
], priceId);
|
|
1834
|
+
const planVersionId = planVersionResolution.planVersionId;
|
|
1835
|
+
if (!planVersionId) {
|
|
1836
|
+
recordWebhookFailure(core, {
|
|
1837
|
+
eventId: event.id,
|
|
1838
|
+
eventType: event.type,
|
|
1839
|
+
reason: planVersionResolution.reason === 'ambiguous'
|
|
1840
|
+
? `Ambiguous price mapping for price_id=${priceId || '(empty)'}`
|
|
1841
|
+
: `Unknown price mapping for price_id=${priceId || '(empty)'}`,
|
|
1842
|
+
payload: event
|
|
1843
|
+
});
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
applySubscriptionSnapshot(core, {
|
|
1847
|
+
userId,
|
|
1848
|
+
subscription,
|
|
1849
|
+
source: 'stripe-webhook'
|
|
1850
|
+
});
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
if (event.type === 'invoice.paid' || event.type === 'invoice.payment_failed') {
|
|
1854
|
+
const invoice = event.data.object;
|
|
1855
|
+
const customerId = String(invoice.customer || '').trim();
|
|
1856
|
+
const subscriptionId = resolveInvoiceSubscriptionId(invoice);
|
|
1857
|
+
const userId = resolveUserIdByCustomer(core, customerId)
|
|
1858
|
+
|| resolveUserIdBySubscription(core, subscriptionId);
|
|
1859
|
+
if (!userId) {
|
|
1860
|
+
throw new Error('Unable to resolve user for invoice event.');
|
|
1861
|
+
}
|
|
1862
|
+
core.createUserBillingRecord(userId);
|
|
1863
|
+
const { priceId, interval } = resolvePriceAndIntervalFromInvoice(invoice);
|
|
1864
|
+
const planVersionResolution = resolvePlanVersionIdForBillingEvent(core, [
|
|
1865
|
+
invoice.parent?.subscription_details?.metadata?.planVersionId,
|
|
1866
|
+
invoice.parent?.subscription_details?.metadata?.plan_version_id,
|
|
1867
|
+
invoice.subscription_details?.metadata?.planVersionId,
|
|
1868
|
+
invoice.subscription_details?.metadata?.plan_version_id
|
|
1869
|
+
], priceId);
|
|
1870
|
+
const planVersionId = planVersionResolution.planVersionId;
|
|
1871
|
+
const existingEntitlement = getEntitlementRow(core, userId);
|
|
1872
|
+
const canonicalState = event.type === 'invoice.payment_failed' ? 'grace' : 'active';
|
|
1873
|
+
const currentPeriodEnd = invoice.lines?.data?.[0]?.period?.end
|
|
1874
|
+
? isoFromUnixSeconds(invoice.lines.data[0].period.end)
|
|
1875
|
+
: null;
|
|
1876
|
+
upsertBillingMirror(core, {
|
|
1877
|
+
userId,
|
|
1878
|
+
stripeCustomerId: customerId || null,
|
|
1879
|
+
stripeSubscriptionId: subscriptionId || null,
|
|
1880
|
+
stripePriceId: priceId || null,
|
|
1881
|
+
stripeStatus: event.type === 'invoice.payment_failed' ? 'past_due' : 'active',
|
|
1882
|
+
planId: planVersionId ? resolvePlanIdForVersion(core, planVersionId) : null,
|
|
1883
|
+
billingInterval: interval || null,
|
|
1884
|
+
canonicalState,
|
|
1885
|
+
currentPeriodEnd,
|
|
1886
|
+
effectiveUntil: currentPeriodEnd
|
|
1887
|
+
});
|
|
1888
|
+
if (!planVersionId) {
|
|
1889
|
+
recordWebhookFailure(core, {
|
|
1890
|
+
eventId: event.id,
|
|
1891
|
+
eventType: event.type,
|
|
1892
|
+
reason: planVersionResolution.reason === 'ambiguous'
|
|
1893
|
+
? `Ambiguous price mapping for price_id=${priceId || '(empty)'}`
|
|
1894
|
+
: `Unknown price mapping for price_id=${priceId || '(empty)'}`,
|
|
1895
|
+
payload: event
|
|
1896
|
+
});
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
upsertCanonicalEntitlement(core, {
|
|
1900
|
+
userId,
|
|
1901
|
+
planVersionId: planVersionId || existingEntitlement?.plan_version_id || core.getDefaultPlanVersionIdByMode('personal', { allowFallback: true }),
|
|
1902
|
+
state: canonicalState,
|
|
1903
|
+
effectiveAt: new Date().toISOString(),
|
|
1904
|
+
effectiveUntil: currentPeriodEnd
|
|
1905
|
+
});
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
});
|
|
1909
|
+
tx();
|
|
1910
|
+
if (duplicate) {
|
|
1911
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1912
|
+
res.end(JSON.stringify({ received: true, duplicate: true }));
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1916
|
+
res.end(JSON.stringify({ received: true }));
|
|
1917
|
+
}
|
|
1918
|
+
catch (error) {
|
|
1919
|
+
recordWebhookFailure(core, {
|
|
1920
|
+
eventId: event.id,
|
|
1921
|
+
eventType: event.type,
|
|
1922
|
+
reason: String(error?.message || error || 'WEBHOOK_PROCESSING_FAILED'),
|
|
1923
|
+
payload: event
|
|
1924
|
+
});
|
|
1925
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1926
|
+
res.end(JSON.stringify({ error: 'Webhook processing failed. Retry expected.' }));
|
|
1927
|
+
}
|
|
1928
|
+
});
|
|
1929
|
+
addRoute('GET', '/api/taskforce/public/pricing', async (req, res) => {
|
|
1930
|
+
if (!ensureCloudPricingRoute(req, res))
|
|
1931
|
+
return;
|
|
1932
|
+
try {
|
|
1933
|
+
const db = core.getDatabaseAdapter();
|
|
1934
|
+
const tenantId = core.getTenantId();
|
|
1935
|
+
// Get all active plans
|
|
1936
|
+
const activePlans = core.listPlanCatalog().filter(p => p.status === 'active');
|
|
1937
|
+
// Get all active price mappings with their amounts
|
|
1938
|
+
const activePrices = db.prepare(`
|
|
1939
|
+
SELECT plan_version_id, billing_interval, stripe_price_id, pricing_type, unit_amount, currency
|
|
1940
|
+
FROM billing_price_mappings
|
|
1941
|
+
WHERE tenant_id = ? AND active = 1
|
|
1942
|
+
`).all(tenantId);
|
|
1943
|
+
const plansPayload = activePlans.map((plan) => {
|
|
1944
|
+
const versions = core.listPlanVersions(plan.planId);
|
|
1945
|
+
// Prefer default, else fallback to highest version number
|
|
1946
|
+
const defaultVersion = versions.find(v => v.isDefault) || [...versions].sort((a, b) => b.versionNumber - a.versionNumber)[0];
|
|
1947
|
+
if (!defaultVersion) {
|
|
1948
|
+
return null; // Skip if no valid version exists (edge case)
|
|
1949
|
+
}
|
|
1950
|
+
const monthPrice = activePrices.find(p => p.plan_version_id === defaultVersion.planVersionId && p.billing_interval === 'month');
|
|
1951
|
+
const yearPrice = activePrices.find(p => p.plan_version_id === defaultVersion.planVersionId && p.billing_interval === 'year');
|
|
1952
|
+
return {
|
|
1953
|
+
planId: plan.planId,
|
|
1954
|
+
displayName: plan.displayName,
|
|
1955
|
+
status: plan.status,
|
|
1956
|
+
defaultVersion: {
|
|
1957
|
+
planVersionId: defaultVersion.planVersionId,
|
|
1958
|
+
versionNumber: defaultVersion.versionNumber,
|
|
1959
|
+
seatLimit: defaultVersion.seatLimit,
|
|
1960
|
+
trialDays: defaultVersion.trialDays,
|
|
1961
|
+
metadata: defaultVersion.metadata
|
|
1962
|
+
},
|
|
1963
|
+
features: Object.values(defaultVersion.features || {}).filter((feature) => {
|
|
1964
|
+
const access = String(feature?.access || '').trim().toLowerCase();
|
|
1965
|
+
return access !== 'disabled';
|
|
1966
|
+
}),
|
|
1967
|
+
pricing: {
|
|
1968
|
+
month: monthPrice ? {
|
|
1969
|
+
pricingType: parsePricingType(monthPrice.pricing_type) || 'stripe',
|
|
1970
|
+
stripePriceId: (parsePricingType(monthPrice.pricing_type) || 'stripe') === 'stripe' ? monthPrice.stripe_price_id : null,
|
|
1971
|
+
unitAmount: (parsePricingType(monthPrice.pricing_type) || 'stripe') === 'free' ? 0 : monthPrice.unit_amount,
|
|
1972
|
+
currency: (parsePricingType(monthPrice.pricing_type) || 'stripe') === 'free' ? null : monthPrice.currency,
|
|
1973
|
+
interval: 'month'
|
|
1974
|
+
} : null,
|
|
1975
|
+
year: yearPrice ? {
|
|
1976
|
+
pricingType: parsePricingType(yearPrice.pricing_type) || 'stripe',
|
|
1977
|
+
stripePriceId: (parsePricingType(yearPrice.pricing_type) || 'stripe') === 'stripe' ? yearPrice.stripe_price_id : null,
|
|
1978
|
+
unitAmount: (parsePricingType(yearPrice.pricing_type) || 'stripe') === 'free' ? 0 : yearPrice.unit_amount,
|
|
1979
|
+
currency: (parsePricingType(yearPrice.pricing_type) || 'stripe') === 'free' ? null : yearPrice.currency,
|
|
1980
|
+
interval: 'year'
|
|
1981
|
+
} : null
|
|
1982
|
+
}
|
|
1983
|
+
};
|
|
1984
|
+
}).filter(Boolean);
|
|
1985
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1986
|
+
res.end(JSON.stringify({
|
|
1987
|
+
asOf: new Date().toISOString(),
|
|
1988
|
+
pricingSource: {
|
|
1989
|
+
environment: requestPricingEnvironment(req),
|
|
1990
|
+
runtimeMode: 'cloud'
|
|
1991
|
+
},
|
|
1992
|
+
plans: plansPayload
|
|
1993
|
+
}));
|
|
1994
|
+
}
|
|
1995
|
+
catch (error) {
|
|
1996
|
+
const statusCode = resolveErrorStatusCode(error, 500);
|
|
1997
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
1998
|
+
res.end(JSON.stringify({ error: String(error?.message || 'Unable to fetch pricing'), code: error?.code }));
|
|
1999
|
+
}
|
|
2000
|
+
});
|
|
2001
|
+
}
|