@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
@@ -14,47 +14,173 @@ vi.mock('./PlanComparisonPage', () => ({
14
14
  }));
15
15
  describe('PlansPage', () => {
16
16
  afterEach(() => {
17
+ vi.useRealTimers();
17
18
  vi.unstubAllGlobals();
18
19
  });
19
- it('shows recovery messaging instead of false checkout success when confirmation fails', async () => {
20
- const fetchMock = vi.fn()
21
- .mockResolvedValueOnce({
22
- ok: true,
23
- json: async () => ({
24
- planId: 'pro',
25
- planVersionId: 'pro-v1',
26
- entitlementState: 'checkout_pending',
27
- gate: 'checkout_pending',
28
- message: 'Complete checkout to continue.'
29
- })
30
- })
31
- .mockResolvedValueOnce({
32
- ok: false,
33
- json: async () => ({
34
- error: 'Checkout session has not completed yet.',
35
- code: 'CHECKOUT_SESSION_INCOMPLETE'
36
- })
37
- })
38
- .mockResolvedValueOnce({
39
- ok: true,
40
- json: async () => ({
41
- planId: 'pro',
42
- planVersionId: 'pro-v1',
43
- entitlementState: 'checkout_pending',
44
- gate: 'plan_selection_required',
45
- message: 'Your previous checkout expired. Choose a plan to continue.',
46
- planSelectionRequired: true
47
- })
20
+ it('shows recovery messaging only after bounded retries are exhausted', async () => {
21
+ let profileSummaryCalls = 0;
22
+ let confirmCalls = 0;
23
+ const fetchMock = vi.fn(async (url) => {
24
+ if (url.includes('/api/taskforce/account/profile-summary')) {
25
+ profileSummaryCalls += 1;
26
+ if (profileSummaryCalls <= 2) {
27
+ return {
28
+ ok: true,
29
+ json: async () => ({
30
+ planId: 'basic',
31
+ planVersionId: 'basic-v1',
32
+ entitlementState: 'checkout_pending',
33
+ gate: 'checkout_pending',
34
+ message: 'Complete checkout to continue.'
35
+ })
36
+ };
37
+ }
38
+ return {
39
+ ok: true,
40
+ json: async () => ({
41
+ planId: 'basic',
42
+ planVersionId: 'basic-v1',
43
+ entitlementState: 'pending_plan_selection',
44
+ gate: 'plan_selection_required',
45
+ message: 'Your previous checkout expired. Choose a plan to continue.',
46
+ planSelectionRequired: true
47
+ })
48
+ };
49
+ }
50
+ if (url.includes('/api/taskforce/billing/checkout-session/confirm')) {
51
+ confirmCalls += 1;
52
+ return {
53
+ ok: false,
54
+ status: 409,
55
+ json: async () => ({
56
+ error: 'Checkout session has not completed yet.',
57
+ code: 'CHECKOUT_SESSION_INCOMPLETE'
58
+ })
59
+ };
60
+ }
61
+ return {
62
+ ok: true,
63
+ json: async () => ({})
64
+ };
48
65
  });
49
66
  vi.stubGlobal('fetch', fetchMock);
50
67
  render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&checkout=success&session_id=cs_test_123'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u1", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path }) }));
51
68
  expect(await screen.findByText('Checkout completed successfully. Finalizing your plan...')).toBeInTheDocument();
52
- expect(await screen.findByText('Your previous checkout expired. Choose a plan to continue.')).toBeInTheDocument();
69
+ expect(confirmCalls).toBe(1);
70
+ await waitFor(() => {
71
+ expect(confirmCalls).toBe(4);
72
+ }, { timeout: 4500 });
73
+ await waitFor(() => {
74
+ expect(screen.getByText('Your previous checkout expired. Choose a plan to continue.')).toBeInTheDocument();
75
+ });
53
76
  expect(screen.queryByText('Checkout completed successfully.')).not.toBeInTheDocument();
77
+ }, 10000);
78
+ it('retries checkout confirmation when the first confirm is incomplete and later succeeds', async () => {
79
+ let profileSummaryCalls = 0;
80
+ let confirmCalls = 0;
81
+ const fetchMock = vi.fn(async (url) => {
82
+ if (url.includes('/api/taskforce/account/profile-summary')) {
83
+ profileSummaryCalls += 1;
84
+ if (profileSummaryCalls <= 2) {
85
+ return {
86
+ ok: true,
87
+ json: async () => ({
88
+ planId: 'basic',
89
+ planVersionId: 'basic-v1',
90
+ entitlementState: 'checkout_pending',
91
+ gate: 'checkout_pending',
92
+ message: 'Complete checkout to continue.'
93
+ })
94
+ };
95
+ }
96
+ return {
97
+ ok: true,
98
+ json: async () => ({
99
+ entitlementState: 'active',
100
+ planId: 'basic',
101
+ planVersionId: 'basic-v1',
102
+ billingInterval: 'month',
103
+ gate: 'ok',
104
+ message: null
105
+ })
106
+ };
107
+ }
108
+ if (url.includes('/api/taskforce/billing/checkout-session/confirm')) {
109
+ confirmCalls += 1;
110
+ if (confirmCalls === 1) {
111
+ return {
112
+ ok: false,
113
+ status: 409,
114
+ json: async () => ({
115
+ error: 'Checkout session has not completed yet.',
116
+ code: 'CHECKOUT_SESSION_INCOMPLETE'
117
+ })
118
+ };
119
+ }
120
+ return {
121
+ ok: true,
122
+ status: 200,
123
+ json: async () => ({ success: true })
124
+ };
125
+ }
126
+ return {
127
+ ok: true,
128
+ json: async () => ({})
129
+ };
130
+ });
131
+ vi.stubGlobal('fetch', fetchMock);
132
+ render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&checkout=success&session_id=cs_test_retry_success'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u1", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path }) }));
133
+ expect(await screen.findByText('Checkout completed successfully. Finalizing your plan...')).toBeInTheDocument();
134
+ expect(await screen.findByText('Checkout completed successfully.')).toBeInTheDocument();
135
+ expect(confirmCalls).toBe(2);
136
+ }, 10000);
137
+ it('does not attempt checkout confirmation before auth session is resolved', async () => {
138
+ let profileSummaryCalls = 0;
139
+ const fetchMock = vi.fn(async (url) => {
140
+ if (url.includes('/api/taskforce/account/profile-summary')) {
141
+ profileSummaryCalls += 1;
142
+ if (profileSummaryCalls <= 2) {
143
+ return {
144
+ ok: true,
145
+ json: async () => ({
146
+ planId: 'basic',
147
+ planVersionId: 'basic-v1',
148
+ entitlementState: 'checkout_pending',
149
+ gate: 'checkout_pending',
150
+ message: 'Complete checkout to continue.'
151
+ })
152
+ };
153
+ }
154
+ return {
155
+ ok: true,
156
+ json: async () => ({
157
+ entitlementState: 'active',
158
+ planId: 'basic',
159
+ planVersionId: 'basic-v1',
160
+ gate: 'ok',
161
+ message: null
162
+ })
163
+ };
164
+ }
165
+ if (url.includes('/api/taskforce/billing/checkout-session/confirm')) {
166
+ return {
167
+ ok: true,
168
+ status: 200,
169
+ json: async () => ({ success: true })
170
+ };
171
+ }
172
+ return {
173
+ ok: true,
174
+ json: async () => ({})
175
+ };
176
+ });
177
+ vi.stubGlobal('fetch', fetchMock);
178
+ const view = render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&checkout=success&session_id=cs_test_auth_wait'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u_auth", runtimeMode: "cloud", authSessionResolved: false, resolveCloudAuthUrl: (path) => path }) }));
179
+ expect(await screen.findByText('Checkout completed successfully. Finalizing your plan...')).toBeInTheDocument();
180
+ expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/api/taskforce/billing/checkout-session/confirm'))).toBe(false);
181
+ view.rerender(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&checkout=success&session_id=cs_test_auth_wait'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u_auth", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path }) }));
54
182
  await waitFor(() => {
55
- expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/taskforce/billing/checkout-session/confirm', expect.objectContaining({
56
- method: 'POST'
57
- }));
183
+ expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/api/taskforce/billing/checkout-session/confirm'))).toBe(true);
58
184
  });
59
185
  });
60
186
  it('shows conflict-specific recovery messaging when the account lands on plans already blocked', async () => {
@@ -62,8 +188,8 @@ describe('PlansPage', () => {
62
188
  .mockResolvedValueOnce({
63
189
  ok: true,
64
190
  json: async () => ({
65
- planId: 'pro',
66
- planVersionId: 'pro-v1',
191
+ planId: 'basic',
192
+ planVersionId: 'basic-v1',
67
193
  entitlementState: 'pending_plan_selection',
68
194
  gate: 'plan_selection_required',
69
195
  message: 'Choose a plan to continue.',
@@ -80,8 +206,8 @@ describe('PlansPage', () => {
80
206
  .mockResolvedValueOnce({
81
207
  ok: true,
82
208
  json: async () => ({
83
- planId: 'pro',
84
- planVersionId: 'pro-v1',
209
+ planId: 'basic',
210
+ planVersionId: 'basic-v1',
85
211
  entitlementState: 'checkout_pending',
86
212
  gate: 'checkout_pending',
87
213
  message: 'Complete checkout to continue.'
@@ -90,8 +216,8 @@ describe('PlansPage', () => {
90
216
  .mockResolvedValueOnce({
91
217
  ok: true,
92
218
  json: async () => ({
93
- planId: 'pro',
94
- planVersionId: 'pro-v1',
219
+ planId: 'basic',
220
+ planVersionId: 'basic-v1',
95
221
  entitlementState: 'pending_plan_selection',
96
222
  gate: 'plan_selection_required',
97
223
  message: 'Choose a plan to continue.',
@@ -154,4 +280,147 @@ describe('PlansPage', () => {
154
280
  expect(await screen.findByRole('button', { name: 'Back to App' })).toBeDisabled();
155
281
  expect(screen.getByText('Choose a plan to continue.')).toHaveAttribute('data-status-type', 'info');
156
282
  });
283
+ it('does not auto-continue after an in-place paid interval update for an already active account', async () => {
284
+ const onContinueToTaskforce = vi.fn();
285
+ let profileSummaryCalls = 0;
286
+ const fetchMock = vi.fn(async (url) => {
287
+ if (url.includes('/api/taskforce/account/profile-summary')) {
288
+ profileSummaryCalls += 1;
289
+ if (profileSummaryCalls <= 2) {
290
+ return {
291
+ ok: true,
292
+ json: async () => ({
293
+ entitlementState: 'active',
294
+ planId: 'basic',
295
+ planVersionId: 'basic-v1',
296
+ gate: 'ok',
297
+ message: null
298
+ })
299
+ };
300
+ }
301
+ return {
302
+ ok: true,
303
+ json: async () => ({
304
+ entitlementState: 'active',
305
+ planId: 'basic',
306
+ planVersionId: 'basic-v1',
307
+ billingInterval: 'year',
308
+ gate: 'ok',
309
+ message: null
310
+ })
311
+ };
312
+ }
313
+ if (url.includes('/api/taskforce/billing/checkout-session')) {
314
+ return {
315
+ ok: true,
316
+ json: async () => ({
317
+ checkoutState: 'updated',
318
+ message: 'Your subscription was updated successfully.'
319
+ })
320
+ };
321
+ }
322
+ return {
323
+ ok: true,
324
+ json: async () => ({})
325
+ };
326
+ });
327
+ vi.stubGlobal('fetch', fetchMock);
328
+ render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&checkout=start&planId=basic&planVersionId=basic-v1&interval=year'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u6", runtimeMode: "local", authSessionResolved: true, resolveCloudAuthUrl: (path) => path, onContinueToTaskforce: onContinueToTaskforce }) }));
329
+ expect(await screen.findByText('Your subscription was updated successfully.')).toBeInTheDocument();
330
+ expect(onContinueToTaskforce).not.toHaveBeenCalled();
331
+ });
332
+ it('does not auto-continue after confirming checkout when the account was already active', async () => {
333
+ const onContinueToTaskforce = vi.fn();
334
+ let profileSummaryCalls = 0;
335
+ const fetchMock = vi.fn(async (url) => {
336
+ if (url.includes('/api/taskforce/account/profile-summary')) {
337
+ profileSummaryCalls += 1;
338
+ if (profileSummaryCalls <= 2) {
339
+ return {
340
+ ok: true,
341
+ json: async () => ({
342
+ entitlementState: 'active',
343
+ planId: 'basic',
344
+ planVersionId: 'basic-v1',
345
+ gate: 'ok',
346
+ message: null
347
+ })
348
+ };
349
+ }
350
+ return {
351
+ ok: true,
352
+ json: async () => ({
353
+ entitlementState: 'active',
354
+ planId: 'team-5',
355
+ planVersionId: 'team-5-v1',
356
+ gate: 'ok',
357
+ message: null
358
+ })
359
+ };
360
+ }
361
+ if (url.includes('/api/taskforce/billing/checkout-session/confirm')) {
362
+ return {
363
+ ok: true,
364
+ json: async () => ({ success: true })
365
+ };
366
+ }
367
+ return {
368
+ ok: true,
369
+ json: async () => ({})
370
+ };
371
+ });
372
+ vi.stubGlobal('fetch', fetchMock);
373
+ render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&checkout=success&session_id=cs_test_existing_active'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u7", runtimeMode: "local", authSessionResolved: true, resolveCloudAuthUrl: (path) => path, onContinueToTaskforce: onContinueToTaskforce }) }));
374
+ expect(await screen.findByText('Checkout completed successfully.')).toBeInTheDocument();
375
+ expect(onContinueToTaskforce).not.toHaveBeenCalled();
376
+ });
377
+ it('shows trial-specific messaging while still allowing return to Taskforce', async () => {
378
+ const fetchMock = vi.fn().mockResolvedValue({
379
+ ok: true,
380
+ json: async () => ({
381
+ entitlementState: 'trialing',
382
+ planId: 'starter',
383
+ planVersionId: 'starter-v1',
384
+ gate: 'ok',
385
+ trialEnd: '2026-05-01T12:00:00.000Z'
386
+ })
387
+ });
388
+ vi.stubGlobal('fetch', fetchMock);
389
+ render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u8", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path, onContinueToTaskforce: vi.fn() }) }));
390
+ expect(await screen.findByText(/Your trial is active through/)).toBeInTheDocument();
391
+ expect(screen.getByRole('button', { name: 'Take Me to Taskforce' })).toBeEnabled();
392
+ });
393
+ it('shows suspended accounts a billing-recovery message and keeps return blocked', async () => {
394
+ const fetchMock = vi.fn().mockResolvedValue({
395
+ ok: true,
396
+ json: async () => ({
397
+ entitlementState: 'suspended',
398
+ planId: 'pro',
399
+ planVersionId: 'pro-v1',
400
+ gate: 'missing_entitlement',
401
+ stripeCustomerId: 'cus_123'
402
+ })
403
+ });
404
+ vi.stubGlobal('fetch', fetchMock);
405
+ render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u9", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path }) }));
406
+ expect(await screen.findByText('Your access is suspended because billing needs attention. Update billing to restore access.')).toBeInTheDocument();
407
+ expect(screen.getByRole('button', { name: 'Back to App' })).toBeDisabled();
408
+ expect(screen.getByText('Your access is suspended because billing needs attention. Update billing to restore access.')).toHaveAttribute('data-status-type', 'error');
409
+ });
410
+ it('shows canceled accounts reactivation messaging instead of generic inactive copy', async () => {
411
+ const fetchMock = vi.fn().mockResolvedValue({
412
+ ok: true,
413
+ json: async () => ({
414
+ entitlementState: 'canceled',
415
+ planId: 'pro',
416
+ planVersionId: 'pro-v1',
417
+ gate: 'missing_entitlement',
418
+ message: 'This generic message should be ignored.'
419
+ })
420
+ });
421
+ vi.stubGlobal('fetch', fetchMock);
422
+ render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u10", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path }) }));
423
+ expect(await screen.findByText('Your access has ended. Choose a plan to reactivate your workspace.')).toBeInTheDocument();
424
+ expect(screen.queryByText('This generic message should be ignored.')).not.toBeInTheDocument();
425
+ });
157
426
  });
@@ -35,6 +35,7 @@ import { getLocale, t } from '../../localization';
35
35
  import { resolveAnnotatedAttachmentDetail } from '../../utils/annotatedAttachments';
36
36
  import { ACCOUNT_PROFILE_SUMMARY_PATH, invalidateAccountProfileSummaryCache, loadAccountProfileSummaryCached } from '../../utils/accountProfileSummaryCache';
37
37
  import { fetchTaskforceApi } from '../../utils/taskforceApiClient';
38
+ import { resolveCommercialLifecyclePresentation } from '../../utils/commercialLifecyclePresentation';
38
39
  import { AppShellHeader } from './AppShellHeader';
39
40
  import { getTaskforceCloudEnvironmentConfig, parseTaskforceCloudEnvironment, resolveTaskforceCloudEnvironmentFromBaseUrl } from '../../config/cloudEnvironment';
40
41
  import { logBootstrapDebug, persistRemoteUiState } from '../../hooks/workspace/workspaceLocalState';
@@ -56,7 +57,6 @@ const WorkflowsModule = React.lazy(() => import('../features/WorkflowsModule').t
56
57
  const AgentsModule = React.lazy(() => import('../features/AgentsModule').then(m => ({ default: m.AgentsModule })));
57
58
  const InitiativesModule = React.lazy(() => import('../features/InitiativesModule').then(m => ({ default: m.InitiativesModule })));
58
59
  const PlansPage = React.lazy(() => import('./PlansPage').then(m => ({ default: m.PlansPage })));
59
- const ACTIVE_COMMERCIAL_STATES = new Set(['active', 'trialing', 'grace']);
60
60
  const PROFILE_AVATAR_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif';
61
61
  const PROFILE_AVATAR_MAX_BYTES = 5 * 1024 * 1024;
62
62
  const resolveSameOriginTaskforcePath = (path) => path;
@@ -928,28 +928,36 @@ export function StandaloneLayout(props) {
928
928
  const referenceLabel = formatInitiativeReference(initiative);
929
929
  return {
930
930
  value: initiative.id,
931
- label: referenceLabel ? `${referenceLabel} - ${initiative.title}` : initiative.title,
932
- selectedLabel: referenceLabel || initiative.title,
931
+ label: referenceLabel || initiative.title,
933
932
  };
934
933
  }), [planningStructure.initiatives]);
935
- const workstreamFilterOptions = useMemo(() => [
936
- ...planningStructure.initiatives.flatMap((initiative) => initiative.workstreams.map((workstream) => {
934
+ const workstreamFilterOptions = useMemo(() => {
935
+ const visibleInitiativeWorkstreams = selectedPlanningInitiativeId
936
+ ? (planningStructure.initiativeById.get(selectedPlanningInitiativeId)?.workstreams || [])
937
+ : planningStructure.initiatives.flatMap((initiative) => initiative.workstreams);
938
+ const initiativeWorkstreams = visibleInitiativeWorkstreams.map((workstream) => {
937
939
  const referenceLabel = formatWorkstreamReference(workstream);
938
940
  return {
939
941
  value: workstream.id,
940
- label: referenceLabel ? `${referenceLabel} - ${workstream.title}` : workstream.title,
941
- selectedLabel: referenceLabel || workstream.title,
942
+ label: referenceLabel || workstream.title,
942
943
  };
943
- })),
944
- ...planningStructure.standaloneWorkstreams.map((workstream) => {
945
- const referenceLabel = formatWorkstreamReference(workstream);
946
- return {
947
- value: workstream.id,
948
- label: referenceLabel ? `${referenceLabel} - ${workstream.title}` : workstream.title,
949
- selectedLabel: referenceLabel || workstream.title,
950
- };
951
- }),
952
- ], [planningStructure.initiatives, planningStructure.standaloneWorkstreams]);
944
+ });
945
+ const standaloneWorkstreams = selectedPlanningInitiativeId
946
+ ? []
947
+ : planningStructure.standaloneWorkstreams.map((workstream) => {
948
+ const referenceLabel = formatWorkstreamReference(workstream);
949
+ return {
950
+ value: workstream.id,
951
+ label: referenceLabel || workstream.title,
952
+ };
953
+ });
954
+ return [...initiativeWorkstreams, ...standaloneWorkstreams];
955
+ }, [
956
+ planningStructure.initiativeById,
957
+ planningStructure.initiatives,
958
+ planningStructure.standaloneWorkstreams,
959
+ selectedPlanningInitiativeId,
960
+ ]);
953
961
  const selectedPlanningTaskIds = useMemo(() => {
954
962
  if (selectedPlanningWorkstreamId) {
955
963
  return new Set(planningStructure.workstreamTaskIds.get(selectedPlanningWorkstreamId) || []);
@@ -1630,9 +1638,8 @@ export function StandaloneLayout(props) {
1630
1638
  setTeamPlanMode(nextMode);
1631
1639
  }, []);
1632
1640
  const hasActiveCommercialEntitlement = useMemo(() => {
1633
- const entitlementState = String(accountProfileSummary?.entitlementState || '').trim().toLowerCase();
1634
- return ACTIVE_COMMERCIAL_STATES.has(entitlementState);
1635
- }, [accountProfileSummary?.entitlementState]);
1641
+ return resolveCommercialLifecyclePresentation(accountProfileSummary).allowReturnToApp;
1642
+ }, [accountProfileSummary]);
1636
1643
  const handleStartCheckout = useCallback(async () => {
1637
1644
  const params = new URLSearchParams();
1638
1645
  params.set('screen', 'plans');
@@ -1828,8 +1835,7 @@ export function StandaloneLayout(props) {
1828
1835
  return;
1829
1836
  }
1830
1837
  const summary = await loadAccountProfileSummary({ force: true });
1831
- const entitlementState = String(summary?.entitlementState || '').trim().toLowerCase();
1832
- if (!ACTIVE_COMMERCIAL_STATES.has(entitlementState)) {
1838
+ if (!resolveCommercialLifecyclePresentation(summary).allowReturnToApp) {
1833
1839
  return;
1834
1840
  }
1835
1841
  setPlansNavigationBusy(true);
@@ -31,15 +31,17 @@ describe('FilterToolbar', () => {
31
31
  expect(screen.getByRole('button', { name: /All Initiatives/i })).toBeInTheDocument();
32
32
  expect(screen.getByRole('button', { name: /All Workstreams/i })).toBeInTheDocument();
33
33
  rerender(_jsx(FilterToolbar, { categoryFilterOptions: [], typeFilterOptions: [], priorities: [], filterCategories: [], setFilterCategories: vi.fn(), filterTypes: [], setFilterTypes: vi.fn(), filterPriorities: [], setFilterPriorities: vi.fn(), filterStatus: [], setFilterStatus: vi.fn(), filterAssignees: [], setFilterAssignees: vi.fn(), assigneeOptions: [], initiativeFilterOptions: [
34
- { value: 'initiative-1', label: 'Stability Sprint' },
34
+ { value: 'initiative-1', label: 'IN-101' },
35
35
  ], selectedInitiativeId: "", setSelectedInitiativeId: setSelectedInitiativeId, workstreamFilterOptions: [
36
- { value: 'workstream-1', label: 'Header polish' },
36
+ { value: 'workstream-1', label: 'WS-202' },
37
37
  ], selectedWorkstreamId: "", setSelectedWorkstreamId: setSelectedWorkstreamId, taskScope: "open", setTaskScope: vi.fn(), showArchive: false, setShowArchive: vi.fn(), fetchArchive: vi.fn(), clearFilters: vi.fn() }));
38
38
  await user.click(screen.getByRole('button', { name: /All Initiatives/i }));
39
- await user.click(screen.getByRole('button', { name: 'Stability Sprint' }));
39
+ expect(screen.queryByText('Stability Sprint')).not.toBeInTheDocument();
40
+ await user.click(screen.getByRole('button', { name: 'IN-101' }));
40
41
  expect(setSelectedInitiativeId).toHaveBeenCalledWith('initiative-1');
41
42
  await user.click(screen.getByRole('button', { name: /All Workstreams/i }));
42
- await user.click(screen.getByRole('button', { name: 'Header polish' }));
43
+ expect(screen.queryByText('Header polish')).not.toBeInTheDocument();
44
+ await user.click(screen.getByRole('button', { name: 'WS-202' }));
43
45
  expect(setSelectedWorkstreamId).toHaveBeenCalledWith('workstream-1');
44
46
  });
45
47
  });
@@ -1,17 +1,5 @@
1
+ import type { AccountProfileSummary } from '../../../../utils/accountProfileSummaryCache';
1
2
  import type { ModalRuntimeMode, ModalThemeName } from './types';
2
- interface AccountProfileSummaryLike {
3
- planId?: string | null;
4
- planName?: string | null;
5
- entitlementState?: string | null;
6
- stripeStatus?: string | null;
7
- effectiveUntil?: string | null;
8
- stripeSubscriptionId?: string | null;
9
- stripeCustomerId?: string | null;
10
- gate?: string | null;
11
- message?: string | null;
12
- billingConflictCode?: 'CHECKOUT_SESSION_EXPIRED' | 'CHECKOUT_SESSION_SUPERSEDED' | null;
13
- planSelectionRequired?: boolean;
14
- }
15
3
  interface AccountHubModalProps {
16
4
  isOpen: boolean;
17
5
  theme: ModalThemeName;
@@ -26,7 +14,7 @@ interface AccountHubModalProps {
26
14
  billingNotice: string | null;
27
15
  billingActionBusy: boolean;
28
16
  billingIntervalChoice: 'month' | 'year';
29
- accountProfileSummary: AccountProfileSummaryLike | null;
17
+ accountProfileSummary: AccountProfileSummary | null;
30
18
  currentWorkspaceId: string;
31
19
  canOpenTeamManagement: boolean;
32
20
  canManageWorkspaceSync: boolean;
@@ -5,21 +5,19 @@ import accountStyles from '../../StandaloneAccount.module.css';
5
5
  import authStyles from '../../AuthShell.module.css';
6
6
  import managementStyles from '../../StandaloneManagement.module.css';
7
7
  import styles from '../../../../Taskforce.module.css';
8
+ import { resolveCommercialLifecyclePresentation } from '../../../../utils/commercialLifecyclePresentation';
8
9
  export function AccountHubModal({ isOpen, theme, runtimeMode, authRequiredForApi, isAuthenticated, hasAuthIdentity, authIdentityLabel, billingLoading, billingError, billingActionError, billingNotice, billingActionBusy, billingIntervalChoice, accountProfileSummary, currentWorkspaceId, canOpenTeamManagement, canManageWorkspaceSync, workspaceCloudSyncEnabled, syncStatusLabel, workspaceSyncError, syncControlBusy, onClose, onOpenWorkspaceAudit, onBillingIntervalChange, onRefreshBilling, onUpdateInterval, onManageBilling, onStartCheckout, onToggleWorkspaceSync, onOpenHelp, }) {
9
- const billingGate = String(accountProfileSummary?.gate || '').trim().toLowerCase();
10
- const billingConflictCode = String(accountProfileSummary?.billingConflictCode || '').trim().toUpperCase();
11
- const billingRecoveryMessage = String(accountProfileSummary?.message || '').trim()
12
- || (billingConflictCode === 'CHECKOUT_SESSION_EXPIRED'
13
- ? 'Your latest checkout expired. Choose a plan to continue.'
14
- : (billingConflictCode === 'CHECKOUT_SESSION_SUPERSEDED'
15
- ? 'A newer checkout is already in progress for this account. Open plans to continue.'
16
- : ''));
17
- const startCheckoutLabel = billingConflictCode === 'CHECKOUT_SESSION_EXPIRED'
18
- || accountProfileSummary?.planSelectionRequired === true
19
- || billingGate === 'plan_selection_required'
20
- ? 'Choose Plan'
21
- : (billingGate === 'checkout_pending' ? 'Open Plans' : 'Start Checkout');
10
+ const lifecyclePresentation = resolveCommercialLifecyclePresentation(accountProfileSummary);
11
+ const billingRecoveryMessage = lifecyclePresentation.message;
12
+ const startCheckoutLabel = lifecyclePresentation.primaryLabel || 'Start Checkout';
22
13
  const resolvedPlanLabel = String(accountProfileSummary?.planName || accountProfileSummary?.planId || 'n/a').trim() || 'n/a';
14
+ const primaryBillingAction = () => {
15
+ if (lifecyclePresentation.primaryAction === 'manage_billing') {
16
+ onManageBilling();
17
+ return;
18
+ }
19
+ onStartCheckout();
20
+ };
23
21
  return (_jsx(Modal, { isOpen: isOpen, onClose: onClose, title: "Account Hub", size: "md", theme: theme, draggable: true, children: _jsxs("div", { className: `${modalStyles.form} ${managementStyles.modalBody}`, children: [_jsx("p", { className: managementStyles.mutedText, children: "Manage sign in, profile, and workspace controls from one place." }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Authentication" }), _jsx("p", { className: managementStyles.sectionText, children: runtimeMode === 'cloud'
24
22
  ? (authRequiredForApi
25
23
  ? (isAuthenticated ? 'Signed in. API access is enabled.' : 'Cloud mode requires sign in before app usage.')
@@ -28,7 +26,7 @@ export function AccountHubModal({ isOpen, theme, runtimeMode, authRequiredForApi
28
26
  ? (hasAuthIdentity
29
27
  ? _jsxs(_Fragment, { children: ["Signed in as ", _jsx("span", { className: accountStyles.accountIdentityEmail, children: authIdentityLabel })] })
30
28
  : 'Signed in')
31
- : 'Signed in as: not signed in' }), runtimeMode === 'cloud' && authRequiredForApi && !isAuthenticated && (_jsx("p", { className: managementStyles.sectionTextTop, children: "Use the `/login` screen to sign in." }))] }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Profile & Account" }), _jsx("p", { className: managementStyles.sectionText, children: "Profile editing and account preferences will live here." })] }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Billing" }), !isAuthenticated ? (_jsx("p", { className: managementStyles.sectionText, children: "Sign in to manage subscription billing." })) : runtimeMode !== 'cloud' ? (_jsx("p", { className: managementStyles.sectionText, children: "Billing remains cloud-backed in local runtime and uses your connected cloud account." })) : (_jsxs(_Fragment, { children: [billingLoading && (_jsx("p", { className: managementStyles.sectionTextSpaced, children: "Loading billing status..." })), billingError && (_jsx("p", { className: `${authStyles.loginError} ${managementStyles.errorText}`, children: billingError })), billingActionError && (_jsx("p", { className: `${authStyles.loginError} ${managementStyles.errorText}`, children: billingActionError })), billingNotice && (_jsx("p", { className: managementStyles.errorText, children: billingNotice })), billingRecoveryMessage && (_jsx("p", { className: managementStyles.sectionText, children: billingRecoveryMessage })), _jsxs("p", { className: managementStyles.sectionTextSpaced, children: ["Plan: ", _jsx("code", { children: resolvedPlanLabel }), ' · ', "Entitlement: ", _jsx("code", { children: String(accountProfileSummary?.entitlementState || 'n/a') })] }), _jsxs("p", { className: managementStyles.sectionText, children: ["Stripe status: ", _jsx("code", { children: String(accountProfileSummary?.stripeStatus || 'n/a') }), accountProfileSummary?.effectiveUntil ? ` · Effective until ${new Date(accountProfileSummary.effectiveUntil).toLocaleString()}` : ''] }), _jsx("div", { className: managementStyles.wrapRow, children: _jsxs("label", { className: `${accountStyles.accountMenuMeta} ${managementStyles.inlineFieldLabel}`, children: ["Interval", _jsxs("select", { className: styles.input, value: billingIntervalChoice, onChange: (event) => onBillingIntervalChange(event.target.value === 'year' ? 'year' : 'month'), disabled: billingActionBusy, children: [_jsx("option", { value: "month", children: "Monthly" }), _jsx("option", { value: "year", children: "Yearly" })] })] }) }), _jsxs("div", { className: managementStyles.actionRowEnd, children: [_jsx("button", { className: styles.cancelBtn, onClick: onRefreshBilling, disabled: billingActionBusy || billingLoading, children: "Refresh Billing" }), _jsx("button", { className: styles.cancelBtn, onClick: onUpdateInterval, disabled: billingActionBusy || !accountProfileSummary?.stripeSubscriptionId, children: "Update Interval" }), _jsx("button", { className: styles.cancelBtn, onClick: onManageBilling, disabled: billingActionBusy || !accountProfileSummary?.stripeCustomerId, children: "Manage Billing" }), _jsx("button", { className: styles.submitBtn, onClick: onStartCheckout, disabled: billingActionBusy, children: billingActionBusy ? 'Working...' : startCheckoutLabel })] })] }))] }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Workspace Audit Log" }), _jsx("p", { className: managementStyles.sectionText, children: "Audit entries are workspace-scoped for the active workspace, not global account history." }), _jsxs("p", { className: managementStyles.sectionText, children: ["Active workspace: ", _jsx("code", { children: currentWorkspaceId })] }), _jsx("div", { className: managementStyles.actionRowEnd, children: _jsx("button", { className: styles.cancelBtn, onClick: onOpenWorkspaceAudit, disabled: !canOpenTeamManagement, children: "Open Workspace Audit" }) })] }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Workspace Cloud Sync" }), _jsx("p", { className: managementStyles.sectionText, children: canManageWorkspaceSync
29
+ : 'Signed in as: not signed in' }), runtimeMode === 'cloud' && authRequiredForApi && !isAuthenticated && (_jsx("p", { className: managementStyles.sectionTextTop, children: "Use the `/login` screen to sign in." }))] }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Profile & Account" }), _jsx("p", { className: managementStyles.sectionText, children: "Profile editing and account preferences will live here." })] }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Billing" }), !isAuthenticated ? (_jsx("p", { className: managementStyles.sectionText, children: "Sign in to manage subscription billing." })) : runtimeMode !== 'cloud' ? (_jsx("p", { className: managementStyles.sectionText, children: "Billing remains cloud-backed in local runtime and uses your connected cloud account." })) : (_jsxs(_Fragment, { children: [billingLoading && (_jsx("p", { className: managementStyles.sectionTextSpaced, children: "Loading billing status..." })), billingError && (_jsx("p", { className: `${authStyles.loginError} ${managementStyles.errorText}`, children: billingError })), billingActionError && (_jsx("p", { className: `${authStyles.loginError} ${managementStyles.errorText}`, children: billingActionError })), billingNotice && (_jsx("p", { className: managementStyles.errorText, children: billingNotice })), billingRecoveryMessage && (_jsx("p", { className: managementStyles.sectionText, children: billingRecoveryMessage })), _jsxs("p", { className: managementStyles.sectionTextSpaced, children: ["Plan: ", _jsx("code", { children: resolvedPlanLabel }), ' · ', "Entitlement: ", _jsx("code", { children: String(accountProfileSummary?.entitlementState || 'n/a') })] }), _jsxs("p", { className: managementStyles.sectionText, children: ["Stripe status: ", _jsx("code", { children: String(accountProfileSummary?.stripeStatus || 'n/a') }), accountProfileSummary?.effectiveUntil ? ` · Effective until ${new Date(accountProfileSummary.effectiveUntil).toLocaleString()}` : ''] }), _jsx("div", { className: managementStyles.wrapRow, children: _jsxs("label", { className: `${accountStyles.accountMenuMeta} ${managementStyles.inlineFieldLabel}`, children: ["Interval", _jsxs("select", { className: styles.input, value: billingIntervalChoice, onChange: (event) => onBillingIntervalChange(event.target.value === 'year' ? 'year' : 'month'), disabled: billingActionBusy, children: [_jsx("option", { value: "month", children: "Monthly" }), _jsx("option", { value: "year", children: "Yearly" })] })] }) }), _jsxs("div", { className: managementStyles.actionRowEnd, children: [_jsx("button", { className: styles.cancelBtn, onClick: onRefreshBilling, disabled: billingActionBusy || billingLoading, children: "Refresh Billing" }), _jsx("button", { className: styles.cancelBtn, onClick: onUpdateInterval, disabled: billingActionBusy || !accountProfileSummary?.stripeSubscriptionId, children: "Update Interval" }), _jsx("button", { className: styles.cancelBtn, onClick: onManageBilling, disabled: billingActionBusy || !accountProfileSummary?.stripeCustomerId, children: "Manage Billing" }), _jsx("button", { className: styles.submitBtn, onClick: primaryBillingAction, disabled: billingActionBusy || (lifecyclePresentation.primaryAction === 'manage_billing' && !accountProfileSummary?.stripeCustomerId), children: billingActionBusy ? 'Working...' : startCheckoutLabel })] })] }))] }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Workspace Audit Log" }), _jsx("p", { className: managementStyles.sectionText, children: "Audit entries are workspace-scoped for the active workspace, not global account history." }), _jsxs("p", { className: managementStyles.sectionText, children: ["Active workspace: ", _jsx("code", { children: currentWorkspaceId })] }), _jsx("div", { className: managementStyles.actionRowEnd, children: _jsx("button", { className: styles.cancelBtn, onClick: onOpenWorkspaceAudit, disabled: !canOpenTeamManagement, children: "Open Workspace Audit" }) })] }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Workspace Cloud Sync" }), _jsx("p", { className: managementStyles.sectionText, children: canManageWorkspaceSync
32
30
  ? (workspaceCloudSyncEnabled
33
31
  ? `Enabled · ${syncStatusLabel}`
34
32
  : 'Disabled')
@@ -17,10 +17,33 @@ describe('AccountHubModal', () => {
17
17
  billingConflictCode: 'CHECKOUT_SESSION_EXPIRED',
18
18
  planSelectionRequired: true
19
19
  }, currentWorkspaceId: "workspace-1", canOpenTeamManagement: false, canManageWorkspaceSync: false, workspaceCloudSyncEnabled: false, syncStatusLabel: "Disabled", workspaceSyncError: null, syncControlBusy: false, onClose: () => { }, onOpenWorkspaceAudit: () => { }, onBillingIntervalChange: () => { }, onRefreshBilling: () => { }, onUpdateInterval: () => { }, onManageBilling: () => { }, onStartCheckout: onStartCheckout, onToggleWorkspaceSync: () => { }, onOpenHelp: () => { } }));
20
- expect(screen.getByText('Your latest checkout expired. Choose a plan to continue.')).toBeInTheDocument();
20
+ expect(screen.getByText('Your previous checkout expired. Choose a plan to continue.')).toBeInTheDocument();
21
21
  expect(screen.getByText('Pro Plan')).toBeInTheDocument();
22
22
  const cta = screen.getByRole('button', { name: 'Choose Plan' });
23
23
  fireEvent.click(cta);
24
24
  expect(onStartCheckout).toHaveBeenCalledTimes(1);
25
25
  });
26
+ it('makes billing recovery the primary action for suspended accounts', () => {
27
+ const onManageBilling = vi.fn();
28
+ render(_jsx(AccountHubModal, { isOpen: true, theme: "dark", runtimeMode: "cloud", authRequiredForApi: true, isAuthenticated: true, hasAuthIdentity: true, authIdentityLabel: "owner@example.com", billingLoading: false, billingError: null, billingActionError: null, billingNotice: null, billingActionBusy: false, billingIntervalChoice: "month", accountProfileSummary: {
29
+ planId: 'pro',
30
+ planName: 'Pro Plan',
31
+ entitlementState: 'suspended',
32
+ stripeCustomerId: 'cus_123'
33
+ }, currentWorkspaceId: "workspace-1", canOpenTeamManagement: false, canManageWorkspaceSync: false, workspaceCloudSyncEnabled: false, syncStatusLabel: "Disabled", workspaceSyncError: null, syncControlBusy: false, onClose: () => { }, onOpenWorkspaceAudit: () => { }, onBillingIntervalChange: () => { }, onRefreshBilling: () => { }, onUpdateInterval: () => { }, onManageBilling: onManageBilling, onStartCheckout: () => { }, onToggleWorkspaceSync: () => { }, onOpenHelp: () => { } }));
34
+ expect(screen.getByText('Your access is suspended because billing needs attention. Update billing to restore access.')).toBeInTheDocument();
35
+ fireEvent.click(screen.getAllByRole('button', { name: 'Manage Billing' })[1]);
36
+ expect(onManageBilling).toHaveBeenCalledTimes(1);
37
+ });
38
+ it('shows trialing accounts as active without blocked recovery copy', () => {
39
+ render(_jsx(AccountHubModal, { isOpen: true, theme: "dark", runtimeMode: "cloud", authRequiredForApi: true, isAuthenticated: true, hasAuthIdentity: true, authIdentityLabel: "owner@example.com", billingLoading: false, billingError: null, billingActionError: null, billingNotice: null, billingActionBusy: false, billingIntervalChoice: "month", accountProfileSummary: {
40
+ planId: 'starter',
41
+ planName: 'Starter',
42
+ entitlementState: 'trialing',
43
+ stripeCustomerId: 'cus_123',
44
+ trialEnd: '2026-05-01T12:00:00.000Z'
45
+ }, currentWorkspaceId: "workspace-1", canOpenTeamManagement: false, canManageWorkspaceSync: false, workspaceCloudSyncEnabled: false, syncStatusLabel: "Disabled", workspaceSyncError: null, syncControlBusy: false, onClose: () => { }, onOpenWorkspaceAudit: () => { }, onBillingIntervalChange: () => { }, onRefreshBilling: () => { }, onUpdateInterval: () => { }, onManageBilling: () => { }, onStartCheckout: () => { }, onToggleWorkspaceSync: () => { }, onOpenHelp: () => { } }));
46
+ expect(screen.getByText(/Your trial is active through/)).toBeInTheDocument();
47
+ expect(screen.queryByText('Your access is suspended because billing needs attention. Update billing to restore access.')).not.toBeInTheDocument();
48
+ });
26
49
  });