@taskforcehq/taskforce 0.3.304 → 0.3.305

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +140 -247
  2. package/dist/TaskforceCore.js +165 -70
  3. package/dist/TaskforceCore.test.js +799 -90
  4. package/dist/compat/workspaceSyncCompat.js +6 -0
  5. package/dist/components/features/DocumentIndex.d.ts +1 -1
  6. package/dist/components/features/DocumentIndex.js +1 -1
  7. package/dist/components/features/DocumentViewer.d.ts +1 -1
  8. package/dist/components/features/DocumentViewer.js +32 -9
  9. package/dist/components/features/DocumentWorkspace.d.ts +5 -22
  10. package/dist/components/features/DocumentWorkspace.js +12 -155
  11. package/dist/components/features/DocumentWorkspace.test.js +193 -6
  12. package/dist/components/features/TaskSettings.js +29 -3
  13. package/dist/components/features/TaskSettings.test.js +42 -1
  14. package/dist/components/features/documentWorkspaceModel.d.ts +24 -0
  15. package/dist/components/features/documentWorkspaceModel.js +57 -0
  16. package/dist/components/features/useDocumentWorkspaceController.d.ts +34 -0
  17. package/dist/components/features/useDocumentWorkspaceController.js +113 -0
  18. package/dist/components/task/TaskForm.d.ts +8 -8
  19. package/dist/components/task/TaskForm.js +358 -221
  20. package/dist/components/task/TaskForm.test.js +456 -381
  21. package/dist/components/task/TaskKanban.js +2 -1
  22. package/dist/components/views/PlanComparisonPage.js +5 -8
  23. package/dist/components/views/PlanComparisonPage.test.js +51 -41
  24. package/dist/components/views/PlansPage.js +126 -84
  25. package/dist/components/views/PlansPage.test.js +308 -39
  26. package/dist/components/views/StandaloneLayout.js +28 -22
  27. package/dist/components/views/panels/FilterToolbar.test.js +6 -4
  28. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +2 -14
  29. package/dist/components/views/standalone/modals/AccountHubModal.js +12 -14
  30. package/dist/components/views/standalone/modals/AccountHubModal.test.js +24 -1
  31. package/dist/config/envSchema.js +1 -1
  32. package/dist/core/AiProfileService.d.ts +6 -1
  33. package/dist/core/AiProfileService.js +161 -16
  34. package/dist/core/AiProfileService.test.js +3 -1
  35. package/dist/core/AiProfiles.test.js +200 -0
  36. package/dist/core/AuthTokenService.test.d.ts +1 -0
  37. package/dist/core/AuthTokenService.test.js +78 -0
  38. package/dist/core/EntitlementsPolicy.test.js +75 -13
  39. package/dist/core/PlanEntitlementService.d.ts +7 -1
  40. package/dist/core/PlanEntitlementService.js +117 -42
  41. package/dist/core/PlanVersionPolicy.test.js +19 -26
  42. package/dist/core/SignupMonetizationSettings.test.js +46 -21
  43. package/dist/core/SystemAdmin.test.js +212 -56
  44. package/dist/core/TaskTaxonomyValidation.test.js +53 -0
  45. package/dist/core/Taskforce.d.ts +3 -0
  46. package/dist/core/Taskforce.js +50 -10
  47. package/dist/core/WorkspacePlanMode.test.js +12 -12
  48. package/dist/core/shared.d.ts +2 -0
  49. package/dist/core/shared.js +11 -0
  50. package/dist/hooks/sync/auth.js +3 -1
  51. package/dist/hooks/sync/transfers.d.ts +2 -1
  52. package/dist/hooks/sync/transfers.js +8 -4
  53. package/dist/hooks/useSyncOrchestrator.d.ts +7 -4
  54. package/dist/hooks/useSyncOrchestrator.js +75 -7
  55. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +80 -10
  56. package/dist/hooks/useTaskMutations.d.ts +2 -1
  57. package/dist/hooks/useTaskMutations.js +12 -1
  58. package/dist/hooks/useTaskMutations.test.js +30 -1
  59. package/dist/hooks/useTaskforce.d.ts +5 -2
  60. package/dist/hooks/useTaskforce.js +17 -5
  61. package/dist/hooks/useTaskforce.sync-behavior.test.js +78 -0
  62. package/dist/hooks/useWorkspaceSyncController.d.ts +3 -1
  63. package/dist/hooks/useWorkspaceSyncController.js +11 -1
  64. package/dist/hooks/useWorkspaceSyncController.test.js +1 -0
  65. package/dist/mcp/adminWorkspaceRegistrar.d.ts +2 -0
  66. package/dist/mcp/adminWorkspaceRegistrar.js +154 -0
  67. package/dist/mcp/collaborationRegistrar.d.ts +2 -0
  68. package/dist/mcp/collaborationRegistrar.js +71 -0
  69. package/dist/mcp/documentAssetRegistrar.d.ts +2 -0
  70. package/dist/mcp/documentAssetRegistrar.js +346 -0
  71. package/dist/mcp/runtime.js +2362 -3530
  72. package/dist/mcp/runtime.test.js +159 -0
  73. package/dist/mcp/taskPlanningRegistrar.d.ts +2 -0
  74. package/dist/mcp/taskPlanningRegistrar.js +418 -0
  75. package/dist/mcp/toolCatalog.d.ts +35 -0
  76. package/dist/mcp/toolCatalog.js +3 -0
  77. package/dist/server/routes/admin.d.ts +1 -5
  78. package/dist/server/routes/admin.js +138 -61
  79. package/dist/server/routes/annotatedAttachments.d.ts +1 -1
  80. package/dist/server/routes/annotatedAttachments.js +1 -1
  81. package/dist/server/routes/auth.d.ts +1 -4
  82. package/dist/server/routes/auth.js +23 -63
  83. package/dist/server/routes/authSupport.d.ts +30 -0
  84. package/dist/server/routes/authSupport.js +81 -0
  85. package/dist/server/routes/billing.d.ts +1 -1
  86. package/dist/server/routes/billing.js +104 -188
  87. package/dist/server/routes/billing.test.js +214 -86
  88. package/dist/server/routes/documentReviews.d.ts +1 -1
  89. package/dist/server/routes/documentReviews.js +1 -1
  90. package/dist/server/routes/documents.d.ts +4 -2
  91. package/dist/server/routes/documents.js +73 -7
  92. package/dist/server/routes/primitives.d.ts +11 -0
  93. package/dist/server/routes/primitives.js +73 -0
  94. package/dist/server/routes/resources.d.ts +1 -1
  95. package/dist/server/routes/resources.js +1 -1
  96. package/dist/server/routes/shared.d.ts +1 -1
  97. package/dist/server/routes/sync.d.ts +1 -1
  98. package/dist/server/routes/sync.js +1 -1
  99. package/dist/server/routes/tasks.d.ts +1 -1
  100. package/dist/server/routes/tasks.js +1 -1
  101. package/dist/server/routes/workspaces.d.ts +1 -1
  102. package/dist/server/routes/workspaces.js +1 -1
  103. package/dist/server/routes.d.ts +2 -9
  104. package/dist/server/routes.js +76 -162
  105. package/dist/server/routes.test.js +672 -30
  106. package/dist/sync/contentSyncState.js +16 -7
  107. package/dist/sync/syncApplyHandlers.test.js +2 -0
  108. package/dist/sync/syncService.d.ts +1 -0
  109. package/dist/sync/syncService.js +8 -0
  110. package/dist/sync/syncService.test.js +14 -0
  111. package/dist/sync/workspaceSyncModel.d.ts +3 -0
  112. package/dist/sync/workspaceSyncModel.js +12 -3
  113. package/dist/sync/workspaceSyncModel.test.js +34 -0
  114. package/dist/sync/workspaceSyncState.d.ts +2 -0
  115. package/dist/sync/workspaceSyncState.js +1 -0
  116. package/dist/ui/assets/{AgentsModule-BLWVbuKP.js → AgentsModule-N2MnQBZk.js} +1 -1
  117. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BlS-cqnl.js → AnnotatedAttachmentWorkspace-BG6P_GVW.js} +1 -1
  118. package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +1 -0
  119. package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +252 -0
  120. package/dist/ui/assets/{InitiativesModule-BjR6iBf9.js → InitiativesModule-Dhm7M8e7.js} +1 -1
  121. package/dist/ui/assets/PlansPage-Cq0zXj3I.js +1 -0
  122. package/dist/ui/assets/TaskSettings-ln0aF7xc.js +9 -0
  123. package/dist/ui/assets/{WorkflowsModule-DnFqdUME.js → WorkflowsModule-BcS4afRn.js} +1 -1
  124. package/dist/ui/assets/{index-Ii0B0rTO.css → index-C2-OXZV7.css} +1 -1
  125. package/dist/ui/assets/index-DcSI5F86.js +6 -0
  126. package/dist/ui/assets/{vendor-icons-I7ZwG1z_.js → vendor-icons-BSUaRwF8.js} +1 -1
  127. package/dist/ui/index.html +3 -3
  128. package/dist/utils/commercialLifecyclePresentation.d.ts +19 -0
  129. package/dist/utils/commercialLifecyclePresentation.js +196 -0
  130. package/package.json +8 -1
  131. package/scripts/check-cloud-mcp.mjs +83 -0
  132. package/scripts/playwright-auth.sh +5 -1
  133. package/scripts/run-billing-performance-smoke.sh +24 -0
  134. package/dist/ui/assets/DocumentWorkspace-BH_6DF6x.js +0 -250
  135. package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +0 -1
  136. package/dist/ui/assets/PlansPage-CSDQ4sLa.js +0 -1
  137. package/dist/ui/assets/TaskSettings-CEDy34Jo.js +0 -9
  138. package/dist/ui/assets/index-DXUdtH1B.js +0 -6
@@ -53,7 +53,8 @@ export function TaskKanban({ tasks, columns: kanbanColumns, groupBy, searchQuery
53
53
  // Sensors
54
54
  const sensors = useSensors(useSensor(PointerSensor, {
55
55
  activationConstraint: {
56
- distance: 5, // Prevent accidental drags
56
+ // Give normal clicks a bit more tolerance so slight trackpad drift still opens the task.
57
+ distance: 10,
57
58
  },
58
59
  }), useSensor(KeyboardSensor, {
59
60
  coordinateGetter: sortableKeyboardCoordinates,
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
3
3
  import { CheckCircle2, Loader2 } from 'lucide-react';
4
4
  import taskforceStyles from '../../Taskforce.module.css';
5
5
  import styles from './PlansPage.module.css';
6
+ import { isCommercialEntitlementActive } from '../../utils/commercialLifecyclePresentation';
6
7
  function isDebugModeEnabled() {
7
8
  const runtimeOverride = globalThis.__DEBUG_MODE__;
8
9
  if (typeof runtimeOverride === 'boolean')
@@ -60,10 +61,6 @@ function getPlanSortRank(plan) {
60
61
  return Math.min(...numericAmounts);
61
62
  return Number.POSITIVE_INFINITY;
62
63
  }
63
- function isActiveEntitlementState(raw) {
64
- const value = String(raw || '').trim().toLowerCase();
65
- return value === 'active' || value === 'trialing' || value === 'grace';
66
- }
67
64
  export function PlanComparisonPage({ heading, subtitle, variant = 'app', chrome, footer, isAuthenticated, authSessionResolved = true, currentPlanId, currentPlanVersionId, currentEntitlementState, canManageCurrentPlan = true, pricingEndpoint, statusBanner, onBack, backLabel, backDisabled = false, topActions, shellOwnsScroll = false, showPageBackButton = true, fallbackPricingSource = null, onSelectPlan }) {
68
65
  const [plans, setPlans] = useState([]);
69
66
  const [pricingSource, setPricingSource] = useState(null);
@@ -138,11 +135,11 @@ export function PlanComparisonPage({ heading, subtitle, variant = 'app', chrome,
138
135
  const hasMonth = isSelectablePrice(monthPrice);
139
136
  const hasYear = isSelectablePrice(yearPrice);
140
137
  const planVersionId = String(plan.defaultVersion?.planVersionId || '').trim();
141
- const isCurrent = Boolean(planVersionId
138
+ const hasActiveEntitlement = isCommercialEntitlementActive(currentEntitlementState);
139
+ const isCurrent = hasActiveEntitlement && (Boolean(planVersionId
142
140
  && currentPlanVersionId
143
- && currentPlanVersionId === planVersionId
144
- && isActiveEntitlementState(currentEntitlementState)) || (!currentPlanVersionId
145
- && Boolean(currentPlanId && currentPlanId === plan.planId));
141
+ && currentPlanVersionId === planVersionId) || (!currentPlanVersionId
142
+ && Boolean(currentPlanId && currentPlanId === plan.planId)));
146
143
  const defaultInterval = hasMonth ? 'month' : (hasYear ? 'year' : null);
147
144
  const defaultPrice = defaultInterval === 'year' ? yearPrice : monthPrice;
148
145
  const canSelectCard = Boolean(planVersionId) && (isCurrent ? canManageCurrentPlan : Boolean(defaultInterval));
@@ -15,11 +15,11 @@ describe('PlanComparisonPage', () => {
15
15
  },
16
16
  plans: [
17
17
  {
18
- planId: 'starter',
19
- displayName: 'Starter',
18
+ planId: 'free',
19
+ displayName: 'Free',
20
20
  status: 'active',
21
21
  defaultVersion: {
22
- planVersionId: 'starter-v1',
22
+ planVersionId: 'free-v1',
23
23
  versionNumber: 1,
24
24
  seatLimit: null,
25
25
  metadata: {}
@@ -31,19 +31,19 @@ describe('PlanComparisonPage', () => {
31
31
  }
32
32
  },
33
33
  {
34
- planId: 'collab',
35
- displayName: 'Collab',
34
+ planId: 'team-5',
35
+ displayName: 'Team 5',
36
36
  status: 'active',
37
37
  defaultVersion: {
38
- planVersionId: 'collab-v2',
38
+ planVersionId: 'team-5-v2',
39
39
  versionNumber: 2,
40
40
  seatLimit: 5,
41
41
  metadata: {}
42
42
  },
43
43
  features: [],
44
44
  pricing: {
45
- month: { pricingType: 'stripe', stripePriceId: 'price_collab_month', unitAmount: 2000, currency: 'usd', interval: 'month' },
46
- year: { pricingType: 'stripe', stripePriceId: 'price_collab_year', unitAmount: 20000, currency: 'usd', interval: 'year' }
45
+ month: { pricingType: 'stripe', stripePriceId: 'price_team_5_month', unitAmount: 2000, currency: 'usd', interval: 'month' },
46
+ year: { pricingType: 'stripe', stripePriceId: 'price_team_5_year', unitAmount: 20000, currency: 'usd', interval: 'year' }
47
47
  }
48
48
  }
49
49
  ]
@@ -66,11 +66,11 @@ describe('PlanComparisonPage', () => {
66
66
  json: async () => ({
67
67
  plans: [
68
68
  {
69
- planId: 'starter',
70
- displayName: 'Starter',
69
+ planId: 'free',
70
+ displayName: 'Free',
71
71
  status: 'active',
72
72
  defaultVersion: {
73
- planVersionId: 'starter-v1',
73
+ planVersionId: 'free-v1',
74
74
  versionNumber: 1,
75
75
  seatLimit: null,
76
76
  metadata: {}
@@ -93,7 +93,17 @@ describe('PlanComparisonPage', () => {
93
93
  expect(screen.queryByText('Pricing source: production (cloud)')).not.toBeInTheDocument();
94
94
  });
95
95
  it('does not mark a plan current when only the plan id matches but the version does not', async () => {
96
- render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: true, currentPlanId: "collab", currentPlanVersionId: "collab-v1", currentEntitlementState: "active", onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
96
+ render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: true, currentPlanId: "team-5", currentPlanVersionId: "team-5-v1", currentEntitlementState: "active", onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
97
+ expect(await screen.findByRole('heading', { name: 'Subscription Plans' })).toBeInTheDocument();
98
+ expect(screen.queryByText('Current Plan')).not.toBeInTheDocument();
99
+ });
100
+ it('marks a trialing entitlement as the current plan', async () => {
101
+ render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: true, currentPlanId: "team-5", currentPlanVersionId: "team-5-v2", currentEntitlementState: "trialing", onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
102
+ expect(await screen.findByRole('heading', { name: 'Subscription Plans' })).toBeInTheDocument();
103
+ expect(screen.getByText('Current Plan')).toBeInTheDocument();
104
+ });
105
+ it('does not mark a canceled entitlement as current through the plan-id fallback path', async () => {
106
+ render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: true, currentPlanId: "team-5", currentPlanVersionId: null, currentEntitlementState: "canceled", onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
97
107
  expect(await screen.findByRole('heading', { name: 'Subscription Plans' })).toBeInTheDocument();
98
108
  expect(screen.queryByText('Current Plan')).not.toBeInTheDocument();
99
109
  });
@@ -105,16 +115,16 @@ describe('PlanComparisonPage', () => {
105
115
  const onSelectPlan = vi.fn();
106
116
  render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: false, onBack: () => { }, backLabel: "Back", onSelectPlan: onSelectPlan }));
107
117
  await screen.findByRole('heading', { name: 'Subscription Plans' });
108
- const starterHeading = screen.getByRole('heading', { name: 'Starter' });
109
- const starterCard = starterHeading.closest('[role="button"]');
110
- expect(starterCard).toBeTruthy();
111
- expect(within(starterCard).getByText('$0.00')).toBeInTheDocument();
112
- expect(within(starterCard).getByText('/ month')).toBeInTheDocument();
113
- fireEvent.click(starterCard);
118
+ const freeHeading = screen.getByRole('heading', { name: 'Free' });
119
+ const freeCard = freeHeading.closest('[role="button"]');
120
+ expect(freeCard).toBeTruthy();
121
+ expect(within(freeCard).getByText('$0.00')).toBeInTheDocument();
122
+ expect(within(freeCard).getByText('/ month')).toBeInTheDocument();
123
+ fireEvent.click(freeCard);
114
124
  await waitFor(() => {
115
125
  expect(onSelectPlan).toHaveBeenCalledWith({
116
- planId: 'starter',
117
- planVersionId: 'starter-v1',
126
+ planId: 'free',
127
+ planVersionId: 'free-v1',
118
128
  interval: 'month',
119
129
  pricingType: 'free',
120
130
  isCurrentPlan: false
@@ -123,7 +133,7 @@ describe('PlanComparisonPage', () => {
123
133
  });
124
134
  it('does not trigger current-plan actions when the current plan is not Stripe-managed', async () => {
125
135
  const onSelectPlan = vi.fn();
126
- render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: true, currentPlanId: "starter", currentPlanVersionId: "starter-v1", currentEntitlementState: "active", canManageCurrentPlan: false, onBack: () => { }, backLabel: "Back", onSelectPlan: onSelectPlan }));
136
+ render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: true, currentPlanId: "free", currentPlanVersionId: "free-v1", currentEntitlementState: "active", canManageCurrentPlan: false, onBack: () => { }, backLabel: "Back", onSelectPlan: onSelectPlan }));
127
137
  await screen.findByRole('heading', { name: 'Subscription Plans' });
128
138
  const currentButton = screen.getByRole('button', { name: 'Current Plan' });
129
139
  expect(currentButton).toBeDisabled();
@@ -139,11 +149,11 @@ describe('PlanComparisonPage', () => {
139
149
  },
140
150
  plans: [
141
151
  {
142
- planId: 'starter',
143
- displayName: 'Starter',
152
+ planId: 'free',
153
+ displayName: 'Free',
144
154
  status: 'active',
145
155
  defaultVersion: {
146
- planVersionId: 'starter-v1',
156
+ planVersionId: 'free-v1',
147
157
  versionNumber: 1,
148
158
  seatLimit: null,
149
159
  metadata: {}
@@ -159,7 +169,7 @@ describe('PlanComparisonPage', () => {
159
169
  });
160
170
  vi.stubGlobal('fetch', fetchMock);
161
171
  render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: false, pricingEndpoint: "https://performance-app.example/api/taskforce/public/pricing", onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
162
- expect(await screen.findByRole('heading', { name: 'Starter' })).toBeInTheDocument();
172
+ expect(await screen.findByRole('heading', { name: 'Free' })).toBeInTheDocument();
163
173
  expect(fetchMock).toHaveBeenCalledTimes(1);
164
174
  expect(fetchMock).toHaveBeenCalledWith('https://performance-app.example/api/taskforce/public/pricing', {
165
175
  method: 'GET',
@@ -177,27 +187,27 @@ describe('PlanComparisonPage', () => {
177
187
  },
178
188
  plans: [
179
189
  {
180
- planId: 'enterprise',
181
- displayName: 'Enterprise',
190
+ planId: 'team-5',
191
+ displayName: 'Team 5',
182
192
  status: 'active',
183
193
  defaultVersion: {
184
- planVersionId: 'enterprise-v3',
194
+ planVersionId: 'team-5-v2',
185
195
  versionNumber: 3,
186
- seatLimit: 100,
196
+ seatLimit: 5,
187
197
  metadata: {}
188
198
  },
189
199
  features: [],
190
200
  pricing: {
191
- month: { pricingType: 'stripe', stripePriceId: 'price_enterprise_month', unitAmount: 12000, currency: 'usd', interval: 'month' },
192
- year: { pricingType: 'stripe', stripePriceId: 'price_enterprise_year', unitAmount: 120000, currency: 'usd', interval: 'year' }
201
+ month: { pricingType: 'stripe', stripePriceId: 'price_team_5_month', unitAmount: 12000, currency: 'usd', interval: 'month' },
202
+ year: { pricingType: 'stripe', stripePriceId: 'price_team_5_year', unitAmount: 120000, currency: 'usd', interval: 'year' }
193
203
  }
194
204
  },
195
205
  {
196
- planId: 'starter',
197
- displayName: 'Starter',
206
+ planId: 'free',
207
+ displayName: 'Free',
198
208
  status: 'active',
199
209
  defaultVersion: {
200
- planVersionId: 'starter-v1',
210
+ planVersionId: 'free-v1',
201
211
  versionNumber: 1,
202
212
  seatLimit: null,
203
213
  metadata: {}
@@ -209,19 +219,19 @@ describe('PlanComparisonPage', () => {
209
219
  }
210
220
  },
211
221
  {
212
- planId: 'pro',
213
- displayName: 'Pro',
222
+ planId: 'basic',
223
+ displayName: 'Basic',
214
224
  status: 'active',
215
225
  defaultVersion: {
216
- planVersionId: 'pro-v2',
226
+ planVersionId: 'basic-v1',
217
227
  versionNumber: 2,
218
- seatLimit: 10,
228
+ seatLimit: 1,
219
229
  metadata: {}
220
230
  },
221
231
  features: [],
222
232
  pricing: {
223
- month: { pricingType: 'stripe', stripePriceId: 'price_pro_month', unitAmount: 3000, currency: 'usd', interval: 'month' },
224
- year: { pricingType: 'stripe', stripePriceId: 'price_pro_year', unitAmount: 30000, currency: 'usd', interval: 'year' }
233
+ month: { pricingType: 'stripe', stripePriceId: 'price_basic_month', unitAmount: 3000, currency: 'usd', interval: 'month' },
234
+ year: { pricingType: 'stripe', stripePriceId: 'price_basic_year', unitAmount: 30000, currency: 'usd', interval: 'year' }
225
235
  }
226
236
  }
227
237
  ]
@@ -231,7 +241,7 @@ describe('PlanComparisonPage', () => {
231
241
  render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: false, onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
232
242
  await screen.findByRole('heading', { name: 'Subscription Plans' });
233
243
  const planNames = screen.getAllByRole('heading', { level: 3 }).map((node) => node.textContent);
234
- expect(planNames).toEqual(['Starter', 'Pro', 'Enterprise']);
244
+ expect(planNames).toEqual(['Free', 'Basic', 'Team 5']);
235
245
  });
236
246
  it('defaults to the same-origin pricing endpoint', async () => {
237
247
  const fetchMock = vi.fn().mockResolvedValue({
@@ -7,18 +7,15 @@ import { PlanComparisonPage } from './PlanComparisonPage';
7
7
  import { AppShellHeader } from './AppShellHeader';
8
8
  import { resolveTaskforceCloudEnvironmentFromBaseUrl } from '../../config/cloudEnvironment';
9
9
  import { ACCOUNT_PROFILE_SUMMARY_PATH, invalidateAccountProfileSummaryCache, loadAccountProfileSummaryCached } from '../../utils/accountProfileSummaryCache';
10
+ import { isCommercialEntitlementActive, resolveCommercialLifecyclePresentation } from '../../utils/commercialLifecyclePresentation';
10
11
  const TRANSIENT_CHECKOUT_QUERY_CLEAR_DELAY_MS = 1600;
11
- const ACTIVE_ENTITLEMENT_STATES = new Set(['active', 'trialing', 'grace']);
12
- const GATE_MESSAGES = {
13
- planSelectionRequired: 'Choose a plan to continue.',
14
- checkoutPending: 'Complete checkout to continue.',
15
- misconfigured: 'Onboarding policy is misconfigured. Contact support or a system administrator.',
16
- missingEntitlement: 'No entitlement is linked to this account.'
17
- };
18
- const BILLING_CONFLICT_MESSAGES = {
19
- CHECKOUT_SESSION_EXPIRED: 'Your previous checkout expired. Choose a plan to continue.',
20
- CHECKOUT_SESSION_SUPERSEDED: 'A newer checkout replaced your previous checkout. Choose a plan to continue.'
21
- };
12
+ const CHECKOUT_CONFIRM_RETRY_DELAYS_MS = [0, 500, 1000, 2000];
13
+ function shouldRetryCheckoutConfirmation(statusCode, errorCode) {
14
+ if (statusCode === 401) {
15
+ return true;
16
+ }
17
+ return String(errorCode || '').trim().toUpperCase() === 'CHECKOUT_SESSION_INCOMPLETE';
18
+ }
22
19
  function parseInterval(raw) {
23
20
  return raw === 'year' ? 'year' : 'month';
24
21
  }
@@ -45,6 +42,7 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
45
42
  const [actionBusy, setActionBusy] = useState(false);
46
43
  const [feedback, setFeedback] = useState(null);
47
44
  const checkoutStartRan = useRef(false);
45
+ const checkoutSuccessHandledRef = useRef(null);
48
46
  const transientQueryClearTimeoutRef = useRef(null);
49
47
  const query = useMemo(() => new URLSearchParams(location.search), [location.search]);
50
48
  const checkoutState = String(query.get('checkout') || '').trim().toLowerCase();
@@ -89,32 +87,9 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
89
87
  ].map((value) => String(value || '').trim()).filter((value, index, all) => value && all.indexOf(value) === index);
90
88
  return endpoints;
91
89
  }, [normalizedApiBaseUrl]);
92
- const hasResolvedActiveEntitlement = useMemo(() => {
93
- const entitlementState = String(accountProfileSummary?.entitlementState || '').trim().toLowerCase();
94
- return ACTIVE_ENTITLEMENT_STATES.has(entitlementState);
95
- }, [accountProfileSummary?.entitlementState]);
96
- const gateMessage = useMemo(() => {
97
- if (hasResolvedActiveEntitlement)
98
- return null;
99
- const billingConflictCode = String(accountProfileSummary?.billingConflictCode || '').trim().toUpperCase();
100
- if (billingConflictCode === 'CHECKOUT_SESSION_EXPIRED')
101
- return BILLING_CONFLICT_MESSAGES.CHECKOUT_SESSION_EXPIRED;
102
- if (billingConflictCode === 'CHECKOUT_SESSION_SUPERSEDED')
103
- return BILLING_CONFLICT_MESSAGES.CHECKOUT_SESSION_SUPERSEDED;
104
- const summaryMessage = String(accountProfileSummary?.message || '').trim();
105
- if (summaryMessage)
106
- return summaryMessage;
107
- const effectiveGateState = String(accountProfileSummary?.gate || gateState || '').trim().toLowerCase();
108
- if (effectiveGateState === 'plan_selection_required')
109
- return GATE_MESSAGES.planSelectionRequired;
110
- if (effectiveGateState === 'checkout_pending')
111
- return GATE_MESSAGES.checkoutPending;
112
- if (effectiveGateState === 'misconfigured')
113
- return GATE_MESSAGES.misconfigured;
114
- if (effectiveGateState === 'missing_entitlement')
115
- return GATE_MESSAGES.missingEntitlement;
116
- return null;
117
- }, [accountProfileSummary?.billingConflictCode, accountProfileSummary?.gate, accountProfileSummary?.message, gateState, hasResolvedActiveEntitlement]);
90
+ const lifecyclePresentation = useMemo(() => resolveCommercialLifecyclePresentation(accountProfileSummary, gateState), [accountProfileSummary, gateState]);
91
+ const hasResolvedActiveEntitlement = useMemo(() => lifecyclePresentation.allowReturnToApp, [lifecyclePresentation.allowReturnToApp]);
92
+ const gateMessage = useMemo(() => lifecyclePresentation.message, [lifecyclePresentation.message]);
118
93
  const clearTransientQueryState = useCallback(() => {
119
94
  const next = new URLSearchParams(location.search);
120
95
  next.delete('checkout');
@@ -157,30 +132,19 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
157
132
  }
158
133
  }, [authUserId, buildCloudBillingUrl, isAuthenticated, onAccountProfileSummaryChange]);
159
134
  const resolveRecoveryMessage = useCallback((summary, fallback) => {
160
- const billingConflictCode = String(summary?.billingConflictCode || '').trim().toUpperCase();
161
- if (billingConflictCode === 'CHECKOUT_SESSION_EXPIRED')
162
- return BILLING_CONFLICT_MESSAGES.CHECKOUT_SESSION_EXPIRED;
163
- if (billingConflictCode === 'CHECKOUT_SESSION_SUPERSEDED')
164
- return BILLING_CONFLICT_MESSAGES.CHECKOUT_SESSION_SUPERSEDED;
165
- const summaryMessage = String(summary?.message || '').trim();
166
- if (summaryMessage)
167
- return summaryMessage;
135
+ const message = resolveCommercialLifecyclePresentation(summary, gateState).message;
136
+ if (message)
137
+ return message;
168
138
  return String(fallback || 'Unable to confirm checkout completion.').trim() || 'Unable to confirm checkout completion.';
139
+ }, [gateState]);
140
+ const shouldAutoContinueAfterActivation = useCallback((summary, fallbackGate) => {
141
+ return !resolveCommercialLifecyclePresentation(summary, fallbackGate).allowReturnToApp;
169
142
  }, []);
170
- const resolveGateFeedbackType = useCallback((message) => {
171
- const normalized = String(message || '').trim();
172
- if (normalized === GATE_MESSAGES.planSelectionRequired
173
- || normalized === GATE_MESSAGES.checkoutPending) {
174
- return 'info';
175
- }
176
- return 'error';
177
- }, []);
178
- const startCheckout = useCallback(async (planVersionId, interval) => {
143
+ const startCheckout = useCallback(async (planVersionId, interval, options) => {
179
144
  if (!planVersionId) {
180
145
  setFeedback({ type: 'error', message: 'Selected plan is missing a default plan version.' });
181
146
  return;
182
147
  }
183
- const hadActiveEntitlement = hasResolvedActiveEntitlement;
184
148
  setActionBusy(true);
185
149
  setFeedback(null);
186
150
  try {
@@ -201,7 +165,7 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
201
165
  invalidateAccountProfileSummaryCache(buildCloudBillingUrl(ACCOUNT_PROFILE_SUMMARY_PATH), { identityKey: authUserId });
202
166
  const summary = await loadAccountProfileSummary({ force: true });
203
167
  const summaryState = String(summary?.entitlementState || '').trim().toLowerCase();
204
- if (!hadActiveEntitlement && ACTIVE_ENTITLEMENT_STATES.has(summaryState)) {
168
+ if (options?.autoContinueOnActivation === true && isCommercialEntitlementActive(summaryState)) {
205
169
  await onContinueToTaskforce?.();
206
170
  }
207
171
  return;
@@ -211,7 +175,7 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
211
175
  invalidateAccountProfileSummaryCache(buildCloudBillingUrl(ACCOUNT_PROFILE_SUMMARY_PATH), { identityKey: authUserId });
212
176
  const summary = await loadAccountProfileSummary({ force: true });
213
177
  const summaryState = String(summary?.entitlementState || '').trim().toLowerCase();
214
- if (!hadActiveEntitlement && ACTIVE_ENTITLEMENT_STATES.has(summaryState)) {
178
+ if (options?.autoContinueOnActivation === true && isCommercialEntitlementActive(summaryState)) {
215
179
  await onContinueToTaskforce?.();
216
180
  }
217
181
  return;
@@ -253,12 +217,13 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
253
217
  finally {
254
218
  setActionBusy(false);
255
219
  }
256
- }, [authUserId, buildCloudBillingUrl, hasResolvedActiveEntitlement, loadAccountProfileSummary, onContinueToTaskforce, runtimeMode]);
257
- const confirmCheckoutSession = useCallback(async (sessionId) => {
220
+ }, [authUserId, buildCloudBillingUrl, loadAccountProfileSummary, onContinueToTaskforce, runtimeMode]);
221
+ const confirmCheckoutSession = useCallback(async (sessionId, options) => {
258
222
  if (!sessionId) {
259
223
  return {
260
224
  confirmed: false,
261
- errorMessage: 'Checkout session is missing.'
225
+ errorMessage: 'Checkout session is missing.',
226
+ retryable: false
262
227
  };
263
228
  }
264
229
  try {
@@ -270,15 +235,17 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
270
235
  });
271
236
  const payload = await res.json().catch(() => ({}));
272
237
  if (!res.ok) {
238
+ const errorCode = String(payload?.code || '').trim();
273
239
  return {
274
240
  confirmed: false,
275
- errorMessage: String(payload?.error || 'Unable to confirm checkout completion.')
241
+ errorMessage: String(payload?.error || 'Unable to confirm checkout completion.'),
242
+ retryable: shouldRetryCheckoutConfirmation(res.status, errorCode)
276
243
  };
277
244
  }
278
245
  invalidateAccountProfileSummaryCache(buildCloudBillingUrl(ACCOUNT_PROFILE_SUMMARY_PATH), { identityKey: authUserId });
279
246
  const summary = await loadAccountProfileSummary({ force: true });
280
247
  const summaryState = String(summary?.entitlementState || '').trim().toLowerCase();
281
- if (ACTIVE_ENTITLEMENT_STATES.has(summaryState)) {
248
+ if (options?.autoContinueOnActivation === true && isCommercialEntitlementActive(summaryState)) {
282
249
  await onContinueToTaskforce?.();
283
250
  }
284
251
  return { confirmed: true };
@@ -286,7 +253,8 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
286
253
  catch {
287
254
  return {
288
255
  confirmed: false,
289
- errorMessage: 'Unable to confirm checkout completion.'
256
+ errorMessage: 'Unable to confirm checkout completion.',
257
+ retryable: true
290
258
  };
291
259
  }
292
260
  }, [authUserId, buildCloudBillingUrl, loadAccountProfileSummary, onContinueToTaskforce]);
@@ -337,15 +305,84 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
337
305
  }
338
306
  }, []);
339
307
  useEffect(() => {
308
+ if (checkoutState !== 'success') {
309
+ checkoutSuccessHandledRef.current = null;
310
+ }
311
+ }, [checkoutState]);
312
+ useEffect(() => {
313
+ let cancelled = false;
314
+ const pendingRetryTimeouts = new Set();
315
+ const waitForRetry = async (delayMs) => {
316
+ if (delayMs <= 0)
317
+ return;
318
+ await new Promise((resolve) => {
319
+ const timeoutId = window.setTimeout(() => {
320
+ pendingRetryTimeouts.delete(timeoutId);
321
+ resolve();
322
+ }, delayMs);
323
+ pendingRetryTimeouts.add(timeoutId);
324
+ });
325
+ };
340
326
  if (checkoutState === 'success') {
341
327
  checkoutStartRan.current = true;
342
328
  setFeedback({ type: 'success', message: 'Checkout completed successfully. Finalizing your plan...' });
329
+ if (!authSessionResolved) {
330
+ return () => {
331
+ cancelled = true;
332
+ pendingRetryTimeouts.forEach((timeoutId) => window.clearTimeout(timeoutId));
333
+ pendingRetryTimeouts.clear();
334
+ };
335
+ }
336
+ if (!isAuthenticated) {
337
+ setFeedback({ type: 'error', message: 'Sign in to finish activating your plan.' });
338
+ return () => {
339
+ cancelled = true;
340
+ pendingRetryTimeouts.forEach((timeoutId) => window.clearTimeout(timeoutId));
341
+ pendingRetryTimeouts.clear();
342
+ };
343
+ }
344
+ const successFlowKey = querySessionId || '__missing_session__';
345
+ if (checkoutSuccessHandledRef.current === successFlowKey) {
346
+ return () => {
347
+ cancelled = true;
348
+ pendingRetryTimeouts.forEach((timeoutId) => window.clearTimeout(timeoutId));
349
+ pendingRetryTimeouts.clear();
350
+ };
351
+ }
352
+ checkoutSuccessHandledRef.current = successFlowKey;
343
353
  void (async () => {
344
- const confirmation = querySessionId
345
- ? await confirmCheckoutSession(querySessionId)
346
- : { confirmed: false, errorMessage: 'Checkout session is missing.' };
354
+ const preConfirmationSummary = await loadAccountProfileSummary({ force: true });
355
+ if (cancelled)
356
+ return;
357
+ let confirmation = querySessionId
358
+ ? { confirmed: false, errorMessage: 'Unable to confirm checkout completion.', retryable: true }
359
+ : { confirmed: false, errorMessage: 'Checkout session is missing.', retryable: false };
360
+ for (const delayMs of CHECKOUT_CONFIRM_RETRY_DELAYS_MS) {
361
+ await waitForRetry(delayMs);
362
+ if (cancelled)
363
+ return;
364
+ confirmation = querySessionId
365
+ ? await confirmCheckoutSession(querySessionId, {
366
+ autoContinueOnActivation: shouldAutoContinueAfterActivation(preConfirmationSummary, gateState)
367
+ })
368
+ : { confirmed: false, errorMessage: 'Checkout session is missing.', retryable: false };
369
+ if (cancelled)
370
+ return;
371
+ if (confirmation.confirmed) {
372
+ setFeedback({ type: 'success', message: 'Checkout completed successfully.' });
373
+ scheduleTransientQueryClear();
374
+ return;
375
+ }
376
+ if (!confirmation.retryable) {
377
+ break;
378
+ }
379
+ }
380
+ if (cancelled)
381
+ return;
347
382
  if (!confirmation.confirmed) {
348
383
  const summary = await loadAccountProfileSummary({ force: true });
384
+ if (cancelled)
385
+ return;
349
386
  setFeedback({
350
387
  type: 'error',
351
388
  message: resolveRecoveryMessage(summary, confirmation.errorMessage)
@@ -353,10 +390,12 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
353
390
  scheduleTransientQueryClear();
354
391
  return;
355
392
  }
356
- setFeedback({ type: 'success', message: 'Checkout completed successfully.' });
357
- scheduleTransientQueryClear();
358
393
  })();
359
- return;
394
+ return () => {
395
+ cancelled = true;
396
+ pendingRetryTimeouts.forEach((timeoutId) => window.clearTimeout(timeoutId));
397
+ pendingRetryTimeouts.clear();
398
+ };
360
399
  }
361
400
  if (checkoutState === 'cancel') {
362
401
  void (async () => {
@@ -371,29 +410,31 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
371
410
  }
372
411
  if ((checkoutState === 'start' || checkoutState === 'free') && isAuthenticated && queryPlanVersionId && !checkoutStartRan.current) {
373
412
  checkoutStartRan.current = true;
374
- void startCheckout(queryPlanVersionId, queryInterval);
413
+ void (async () => {
414
+ const preCheckoutSummary = await loadAccountProfileSummary({ force: true });
415
+ await startCheckout(queryPlanVersionId, queryInterval, {
416
+ autoContinueOnActivation: shouldAutoContinueAfterActivation(preCheckoutSummary, gateState)
417
+ });
418
+ })();
419
+ return;
420
+ }
421
+ }, [authSessionResolved, checkoutState, confirmCheckoutSession, gateState, isAuthenticated, loadAccountProfileSummary, queryInterval, queryPlanVersionId, querySessionId, resolveRecoveryMessage, scheduleTransientQueryClear, shouldAutoContinueAfterActivation, startCheckout]);
422
+ useEffect(() => {
423
+ if (checkoutState === 'success' || checkoutState === 'cancel' || checkoutState === 'start' || checkoutState === 'free') {
375
424
  return;
376
425
  }
377
426
  if (gateMessage) {
378
- setFeedback({ type: resolveGateFeedbackType(gateMessage), message: gateMessage });
427
+ setFeedback({ type: lifecyclePresentation.statusTone, message: gateMessage });
379
428
  return;
380
429
  }
381
430
  setFeedback((current) => {
382
431
  if (!current || current.type !== 'error')
383
432
  return current;
384
- if (current.message === GATE_MESSAGES.planSelectionRequired)
385
- return null;
386
- if (current.message === GATE_MESSAGES.misconfigured)
387
- return null;
388
- if (current.message === GATE_MESSAGES.missingEntitlement)
389
- return null;
390
- if (current.message === BILLING_CONFLICT_MESSAGES.CHECKOUT_SESSION_EXPIRED)
391
- return null;
392
- if (current.message === BILLING_CONFLICT_MESSAGES.CHECKOUT_SESSION_SUPERSEDED)
433
+ if (current.message === lifecyclePresentation.message)
393
434
  return null;
394
435
  return current;
395
436
  });
396
- }, [checkoutState, confirmCheckoutSession, gateMessage, isAuthenticated, loadAccountProfileSummary, queryInterval, queryPlanVersionId, querySessionId, resolveGateFeedbackType, resolveRecoveryMessage, scheduleTransientQueryClear, startCheckout]);
437
+ }, [checkoutState, gateMessage, lifecyclePresentation.message, lifecyclePresentation.statusTone]);
397
438
  const handleSelectPlan = useCallback(async (intent) => {
398
439
  if (!isAuthenticated) {
399
440
  const nextCheckoutState = intent.pricingType === 'free' ? 'free' : 'start';
@@ -414,12 +455,13 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
414
455
  await openBillingPortal();
415
456
  return;
416
457
  }
458
+ const autoContinueOnActivation = shouldAutoContinueAfterActivation(accountProfileSummary, gateState);
417
459
  if (intent.pricingType === 'free') {
418
- await startCheckout(intent.planVersionId, intent.interval);
460
+ await startCheckout(intent.planVersionId, intent.interval, { autoContinueOnActivation });
419
461
  return;
420
462
  }
421
- await startCheckout(intent.planVersionId, intent.interval);
422
- }, [isAuthenticated, navigate, openBillingPortal, startCheckout]);
463
+ await startCheckout(intent.planVersionId, intent.interval, { autoContinueOnActivation });
464
+ }, [accountProfileSummary, gateState, isAuthenticated, navigate, openBillingPortal, shouldAutoContinueAfterActivation, startCheckout]);
423
465
  const handleBack = useCallback(() => {
424
466
  if (hasResolvedActiveEntitlement && onContinueToTaskforce) {
425
467
  void onContinueToTaskforce();