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