@taskforcehq/taskforce 0.3.302 → 0.3.303

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 (77) hide show
  1. package/dist/Taskforce.module.css +11 -0
  2. package/dist/TaskforceCore.js +59 -9
  3. package/dist/TaskforceCore.test.js +717 -14
  4. package/dist/components/task/TaskKanban.test.js +17 -0
  5. package/dist/components/views/PlansPage.d.ts +3 -1
  6. package/dist/components/views/PlansPage.js +110 -51
  7. package/dist/components/views/PlansPage.test.d.ts +1 -0
  8. package/dist/components/views/PlansPage.test.js +100 -0
  9. package/dist/components/views/StandaloneLayout.d.ts +1 -0
  10. package/dist/components/views/StandaloneLayout.js +106 -151
  11. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +8 -3
  12. package/dist/components/views/standalone/modals/AccountHubModal.js +16 -2
  13. package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +1 -0
  14. package/dist/components/views/standalone/modals/AccountHubModal.test.js +26 -0
  15. package/dist/core/EntitlementsPolicy.test.js +2 -2
  16. package/dist/core/GlobalSettingsService.js +78 -18
  17. package/dist/core/PlanEntitlementService.d.ts +29 -5
  18. package/dist/core/PlanEntitlementService.js +297 -219
  19. package/dist/core/SignupMonetizationSettings.test.js +173 -49
  20. package/dist/core/SystemAdmin.test.js +186 -96
  21. package/dist/core/Taskforce.d.ts +51 -10
  22. package/dist/core/Taskforce.js +218 -49
  23. package/dist/core/Taskforce.mcpAuth.test.js +0 -2
  24. package/dist/core/UserProfileAvatarDrafts.test.js +12 -3
  25. package/dist/core/types.d.ts +11 -12
  26. package/dist/hooks/useSyncOrchestrator.js +17 -3
  27. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +97 -5
  28. package/dist/hooks/useTaskforce.d.ts +3 -0
  29. package/dist/hooks/useTaskforce.js +9 -4
  30. package/dist/mcp/runtime.js +90 -29
  31. package/dist/mcp/runtime.test.js +272 -81
  32. package/dist/migrations/billingSchemaParity.test.js +3 -0
  33. package/dist/migrations/taskSchemaMigrations.js +15 -0
  34. package/dist/server/index.cookieProxy.test.js +1 -0
  35. package/dist/server/index.js +2 -0
  36. package/dist/server/index.test.js +2 -1
  37. package/dist/server/mockStripe.d.ts +1 -0
  38. package/dist/server/mockStripe.js +2 -0
  39. package/dist/server/routes/admin.js +80 -23
  40. package/dist/server/routes/auth.d.ts +1 -0
  41. package/dist/server/routes/auth.js +194 -53
  42. package/dist/server/routes/billing.js +506 -23
  43. package/dist/server/routes/billing.test.js +937 -45
  44. package/dist/server/routes/shared.js +5 -3
  45. package/dist/server/routes/sync.integration.test.js +160 -0
  46. package/dist/server/routes/sync.js +15 -5
  47. package/dist/server/routes.js +3 -3
  48. package/dist/server/routes.test.js +287 -76
  49. package/dist/sync/cloudSyncApi.d.ts +1 -0
  50. package/dist/sync/contentSyncState.d.ts +10 -0
  51. package/dist/sync/contentSyncState.js +277 -16
  52. package/dist/sync/syncService.d.ts +1 -0
  53. package/dist/sync/syncService.js +21 -8
  54. package/dist/sync/workspacePullFeed.d.ts +10 -0
  55. package/dist/sync/workspacePullFeed.js +111 -63
  56. package/dist/sync/workspacePullFeed.test.js +112 -0
  57. package/dist/ui/assets/{AgentsModule-JG5jc3he.js → AgentsModule-CpsTmrIW.js} +1 -1
  58. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-Dvi02Y7q.js → AnnotatedAttachmentWorkspace-C_TmXZwA.js} +1 -1
  59. package/dist/ui/assets/{DocumentWorkspace-iys3zfca.js → DocumentWorkspace-C3GyzrWm.js} +1 -1
  60. package/dist/ui/assets/{InitiativesModule-DJvLJI3A.js → InitiativesModule-4-Rh2K2n.js} +1 -1
  61. package/dist/ui/assets/PlansPage-BP7AYOqr.js +1 -0
  62. package/dist/ui/assets/{TaskSettings-Bqd4cCzW.js → TaskSettings-BOC5F6Ag.js} +1 -1
  63. package/dist/ui/assets/{WorkflowsModule-BguHeQaV.js → WorkflowsModule-CGk9s3NJ.js} +1 -1
  64. package/dist/ui/assets/index-CLIMgR6g.js +6 -0
  65. package/dist/ui/assets/index-DneETxcu.css +1 -0
  66. package/dist/ui/assets/{vendor-icons-C8EED3Oh.js → vendor-icons-pWocEipT.js} +1 -1
  67. package/dist/ui/index.html +3 -3
  68. package/dist/utils/accountProfileSummaryCache.d.ts +31 -0
  69. package/dist/utils/accountProfileSummaryCache.js +97 -0
  70. package/dist/utils/accountProfileSummaryCache.test.d.ts +1 -0
  71. package/dist/utils/accountProfileSummaryCache.test.js +63 -0
  72. package/package.json +3 -3
  73. package/dist/ui/assets/PlansPage-BMPlpFpw.js +0 -1
  74. package/dist/ui/assets/index-MXeWoebC.css +0 -1
  75. package/dist/ui/assets/index-YJ4k5QNa.js +0 -6
  76. package/dist/utils/billingStatusCache.d.ts +0 -21
  77. package/dist/utils/billingStatusCache.js +0 -70
@@ -43,4 +43,21 @@ describe('TaskKanban schedule expired behavior', () => {
43
43
  expect(screen.getByText('Mon')).toBeInTheDocument();
44
44
  expect(screen.getAllByText('Expired dual visibility task')).toHaveLength(2);
45
45
  });
46
+ it('shows filtered and total counts per column', () => {
47
+ const mon = dateOffset(1);
48
+ const tue = dateOffset(2);
49
+ const wed = dateOffset(3);
50
+ const thu = dateOffset(4);
51
+ const fri = dateOffset(5);
52
+ const sat = dateOffset(6);
53
+ const sun = dateOffset(7);
54
+ const visibleTask = makeTask({ id: 'task-visible', title: 'Visible task', scheduledDate: mon });
55
+ const hiddenTask = makeTask({ id: 'task-hidden', title: 'Hidden task', scheduledDate: mon });
56
+ render(_jsx(TaskKanban, { tasks: [visibleTask], allTasks: [visibleTask, hiddenTask], columns: [
57
+ { value: 'backlog', label: 'Backlog', color: '#64748b', icon: 'Inbox' },
58
+ { value: 'expired', label: 'Expired', color: '#ef4444', icon: 'AlertTriangle' },
59
+ { value: 'mon', label: 'Mon', color: '#3b82f6', icon: 'Calendar' }
60
+ ], groupBy: "schedule", scheduleDates: { mon, tue, wed, thu, fri, sat, sun }, scheduleFilteredTaskIds: [visibleTask.id], onUpdateTask: vi.fn(), onTaskClick: vi.fn() }));
61
+ expect(screen.getByText('1 / 2')).toBeInTheDocument();
62
+ });
46
63
  });
@@ -1,5 +1,6 @@
1
1
  interface PlansPageProps {
2
2
  isAuthenticated: boolean;
3
+ authUserId?: string | null;
3
4
  runtimeMode: string;
4
5
  authSessionResolved: boolean;
5
6
  currentTheme?: import('../../types').TaskforceTheme;
@@ -7,8 +8,9 @@ interface PlansPageProps {
7
8
  currentWorkspaceId?: string | null;
8
9
  apiBaseUrl?: string | null;
9
10
  connectedEnvironmentSource?: string | null;
11
+ resolveCloudAuthUrl?: (path: string) => string;
10
12
  embedded?: boolean;
11
13
  shellOwnsScroll?: boolean;
12
14
  }
13
- export declare function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, currentTheme, projectName, currentWorkspaceId, apiBaseUrl, connectedEnvironmentSource, embedded, shellOwnsScroll }: PlansPageProps): import("react/jsx-runtime").JSX.Element;
15
+ export declare function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessionResolved, currentTheme, projectName, currentWorkspaceId, apiBaseUrl, connectedEnvironmentSource, resolveCloudAuthUrl, embedded, shellOwnsScroll }: PlansPageProps): import("react/jsx-runtime").JSX.Element;
14
16
  export {};
@@ -6,14 +6,19 @@ import styles from './PlansPage.module.css';
6
6
  import { PlanComparisonPage } from './PlanComparisonPage';
7
7
  import { AppShellHeader } from './AppShellHeader';
8
8
  import { resolveTaskforceCloudEnvironmentFromBaseUrl } from '../../config/cloudEnvironment';
9
- import { invalidateBillingStatusCache, loadBillingStatusCached } from '../../utils/billingStatusCache';
9
+ import { ACCOUNT_PROFILE_SUMMARY_PATH, invalidateAccountProfileSummaryCache, loadAccountProfileSummaryCached } from '../../utils/accountProfileSummaryCache';
10
10
  const TRANSIENT_CHECKOUT_QUERY_CLEAR_DELAY_MS = 1600;
11
11
  const ACTIVE_ENTITLEMENT_STATES = new Set(['active', 'trialing', 'grace']);
12
12
  const GATE_MESSAGES = {
13
13
  planSelectionRequired: 'Choose a plan to continue.',
14
- misconfigured: 'Signup monetization is misconfigured. Contact support or a system administrator.',
14
+ checkoutPending: 'Complete checkout to continue.',
15
+ misconfigured: 'Onboarding policy is misconfigured. Contact support or a system administrator.',
15
16
  missingEntitlement: 'No entitlement is linked to this account.'
16
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
+ };
17
22
  function parseInterval(raw) {
18
23
  return raw === 'year' ? 'year' : 'month';
19
24
  }
@@ -33,10 +38,10 @@ function buildEmbeddedPlansPath(params = {}) {
33
38
  }
34
39
  return `/?${query.toString()}`;
35
40
  }
36
- export function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, currentTheme = 'dark', projectName, currentWorkspaceId, apiBaseUrl, connectedEnvironmentSource, embedded = false, shellOwnsScroll = false }) {
41
+ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessionResolved, currentTheme = 'dark', projectName, currentWorkspaceId, apiBaseUrl, connectedEnvironmentSource, resolveCloudAuthUrl, embedded = false, shellOwnsScroll = false }) {
37
42
  const navigate = useNavigate();
38
43
  const location = useLocation();
39
- const [billingStatus, setBillingStatus] = useState(null);
44
+ const [accountProfileSummary, setAccountProfileSummary] = useState(null);
40
45
  const [actionBusy, setActionBusy] = useState(false);
41
46
  const [feedback, setFeedback] = useState(null);
42
47
  const checkoutStartRan = useRef(false);
@@ -63,6 +68,9 @@ export function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, c
63
68
  return normalizedPath;
64
69
  return `${normalizedApiBaseUrl}${normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`}`;
65
70
  }, [normalizedApiBaseUrl]);
71
+ const buildCloudBillingUrl = useCallback((path) => {
72
+ return resolveCloudAuthUrl ? resolveCloudAuthUrl(path) : buildApiUrl(path);
73
+ }, [buildApiUrl, resolveCloudAuthUrl]);
66
74
  const closeEmbeddedPlansScreen = useCallback(() => {
67
75
  const params = new URLSearchParams(location.search);
68
76
  params.delete('screen');
@@ -82,20 +90,31 @@ export function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, c
82
90
  return endpoints;
83
91
  }, [normalizedApiBaseUrl]);
84
92
  const hasResolvedActiveEntitlement = useMemo(() => {
85
- const entitlementState = String(billingStatus?.entitlementState || '').trim().toLowerCase();
93
+ const entitlementState = String(accountProfileSummary?.entitlementState || '').trim().toLowerCase();
86
94
  return ACTIVE_ENTITLEMENT_STATES.has(entitlementState);
87
- }, [billingStatus?.entitlementState]);
95
+ }, [accountProfileSummary?.entitlementState]);
88
96
  const gateMessage = useMemo(() => {
89
97
  if (hasResolvedActiveEntitlement)
90
98
  return null;
91
- if (gateState === 'plan_selection_required')
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')
92
109
  return GATE_MESSAGES.planSelectionRequired;
93
- if (gateState === 'misconfigured')
110
+ if (effectiveGateState === 'checkout_pending')
111
+ return GATE_MESSAGES.checkoutPending;
112
+ if (effectiveGateState === 'misconfigured')
94
113
  return GATE_MESSAGES.misconfigured;
95
- if (gateState === 'missing_entitlement')
114
+ if (effectiveGateState === 'missing_entitlement')
96
115
  return GATE_MESSAGES.missingEntitlement;
97
116
  return null;
98
- }, [gateState, hasResolvedActiveEntitlement]);
117
+ }, [accountProfileSummary?.billingConflictCode, accountProfileSummary?.gate, accountProfileSummary?.message, gateState, hasResolvedActiveEntitlement]);
99
118
  const clearTransientQueryState = useCallback(() => {
100
119
  const next = new URLSearchParams(location.search);
101
120
  next.delete('checkout');
@@ -116,21 +135,37 @@ export function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, c
116
135
  transientQueryClearTimeoutRef.current = null;
117
136
  }, TRANSIENT_CHECKOUT_QUERY_CLEAR_DELAY_MS);
118
137
  }, [clearTransientQueryState]);
119
- const loadBillingStatus = useCallback(async () => {
120
- const billingStatusUrl = buildApiUrl('/api/taskforce/billing/status');
138
+ const loadAccountProfileSummary = useCallback(async (options) => {
139
+ const accountProfileSummaryUrl = buildCloudBillingUrl(ACCOUNT_PROFILE_SUMMARY_PATH);
121
140
  if (!isAuthenticated) {
122
- setBillingStatus(null);
123
- invalidateBillingStatusCache(billingStatusUrl);
124
- return;
141
+ setAccountProfileSummary(null);
142
+ invalidateAccountProfileSummaryCache(accountProfileSummaryUrl, { identityKey: authUserId });
143
+ return null;
125
144
  }
126
145
  try {
127
- const payload = await loadBillingStatusCached(billingStatusUrl);
128
- setBillingStatus(payload);
146
+ const payload = await loadAccountProfileSummaryCached(accountProfileSummaryUrl, {
147
+ identityKey: authUserId,
148
+ force: options?.force === true
149
+ });
150
+ setAccountProfileSummary(payload);
151
+ return payload;
129
152
  }
130
153
  catch {
131
154
  // best effort only
155
+ return null;
132
156
  }
133
- }, [buildApiUrl, isAuthenticated]);
157
+ }, [authUserId, buildCloudBillingUrl, isAuthenticated]);
158
+ const resolveRecoveryMessage = useCallback((summary, fallback) => {
159
+ const billingConflictCode = String(summary?.billingConflictCode || '').trim().toUpperCase();
160
+ if (billingConflictCode === 'CHECKOUT_SESSION_EXPIRED')
161
+ return BILLING_CONFLICT_MESSAGES.CHECKOUT_SESSION_EXPIRED;
162
+ if (billingConflictCode === 'CHECKOUT_SESSION_SUPERSEDED')
163
+ return BILLING_CONFLICT_MESSAGES.CHECKOUT_SESSION_SUPERSEDED;
164
+ const summaryMessage = String(summary?.message || '').trim();
165
+ if (summaryMessage)
166
+ return summaryMessage;
167
+ return String(fallback || 'Unable to confirm checkout completion.').trim() || 'Unable to confirm checkout completion.';
168
+ }, []);
134
169
  const startCheckout = useCallback(async (planVersionId, interval) => {
135
170
  if (!planVersionId) {
136
171
  setFeedback({ type: 'error', message: 'Selected plan is missing a default plan version.' });
@@ -139,7 +174,7 @@ export function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, c
139
174
  setActionBusy(true);
140
175
  setFeedback(null);
141
176
  try {
142
- const res = await fetch(buildApiUrl('/api/taskforce/billing/checkout-session'), {
177
+ const res = await fetch(buildCloudBillingUrl('/api/taskforce/billing/checkout-session'), {
143
178
  method: 'POST',
144
179
  credentials: 'include',
145
180
  headers: { 'Content-Type': 'application/json' },
@@ -153,20 +188,20 @@ export function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, c
153
188
  const checkoutState = String(payload?.checkoutState || '').trim().toLowerCase();
154
189
  if (checkoutState === 'free') {
155
190
  setFeedback({ type: 'success', message: 'Plan selected. Your access is ready.' });
156
- invalidateBillingStatusCache(buildApiUrl('/api/taskforce/billing/status'));
157
- await loadBillingStatus();
191
+ invalidateAccountProfileSummaryCache(buildCloudBillingUrl(ACCOUNT_PROFILE_SUMMARY_PATH), { identityKey: authUserId });
192
+ await loadAccountProfileSummary();
158
193
  return;
159
194
  }
160
195
  if (checkoutState === 'updated') {
161
196
  setFeedback({ type: 'success', message: String(payload?.message || 'Your subscription was updated successfully.') });
162
- invalidateBillingStatusCache(buildApiUrl('/api/taskforce/billing/status'));
163
- await loadBillingStatus();
197
+ invalidateAccountProfileSummaryCache(buildCloudBillingUrl(ACCOUNT_PROFILE_SUMMARY_PATH), { identityKey: authUserId });
198
+ await loadAccountProfileSummary();
164
199
  return;
165
200
  }
166
201
  if (checkoutState === 'already_current_plan') {
167
202
  setFeedback({ type: 'success', message: String(payload?.message || 'You are already on this plan.') });
168
- invalidateBillingStatusCache(buildApiUrl('/api/taskforce/billing/status'));
169
- await loadBillingStatus();
203
+ invalidateAccountProfileSummaryCache(buildCloudBillingUrl(ACCOUNT_PROFILE_SUMMARY_PATH), { identityKey: authUserId });
204
+ await loadAccountProfileSummary();
170
205
  return;
171
206
  }
172
207
  const returnBaseUrl = String(payload?.returnBaseUrl || '').trim();
@@ -200,12 +235,16 @@ export function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, c
200
235
  finally {
201
236
  setActionBusy(false);
202
237
  }
203
- }, [buildApiUrl, loadBillingStatus]);
238
+ }, [authUserId, buildCloudBillingUrl, loadAccountProfileSummary, runtimeMode]);
204
239
  const confirmCheckoutSession = useCallback(async (sessionId) => {
205
- if (!sessionId)
206
- return false;
240
+ if (!sessionId) {
241
+ return {
242
+ confirmed: false,
243
+ errorMessage: 'Checkout session is missing.'
244
+ };
245
+ }
207
246
  try {
208
- const res = await fetch(buildApiUrl('/api/taskforce/billing/checkout-session/confirm'), {
247
+ const res = await fetch(buildCloudBillingUrl('/api/taskforce/billing/checkout-session/confirm'), {
209
248
  method: 'POST',
210
249
  credentials: 'include',
211
250
  headers: { 'Content-Type': 'application/json' },
@@ -213,18 +252,22 @@ export function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, c
213
252
  });
214
253
  const payload = await res.json().catch(() => ({}));
215
254
  if (!res.ok) {
216
- setFeedback({ type: 'error', message: String(payload?.error || 'Unable to confirm checkout completion.') });
217
- return false;
255
+ return {
256
+ confirmed: false,
257
+ errorMessage: String(payload?.error || 'Unable to confirm checkout completion.')
258
+ };
218
259
  }
219
- invalidateBillingStatusCache(buildApiUrl('/api/taskforce/billing/status'));
220
- await loadBillingStatus();
221
- return true;
260
+ invalidateAccountProfileSummaryCache(buildCloudBillingUrl(ACCOUNT_PROFILE_SUMMARY_PATH), { identityKey: authUserId });
261
+ await loadAccountProfileSummary();
262
+ return { confirmed: true };
222
263
  }
223
264
  catch {
224
- setFeedback({ type: 'error', message: 'Unable to confirm checkout completion.' });
225
- return false;
265
+ return {
266
+ confirmed: false,
267
+ errorMessage: 'Unable to confirm checkout completion.'
268
+ };
226
269
  }
227
- }, [buildApiUrl, loadBillingStatus]);
270
+ }, [authUserId, buildCloudBillingUrl, loadAccountProfileSummary]);
228
271
  const openBillingPortal = useCallback(async () => {
229
272
  if (!isAuthenticated) {
230
273
  navigate(`/login?mode=login&next=${encodeURIComponent(buildEmbeddedPlansPath())}`);
@@ -233,7 +276,7 @@ export function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, c
233
276
  setActionBusy(true);
234
277
  setFeedback(null);
235
278
  try {
236
- const res = await fetch(buildApiUrl('/api/taskforce/billing/portal-session'), {
279
+ const res = await fetch(buildCloudBillingUrl('/api/taskforce/billing/portal-session'), {
237
280
  method: 'POST',
238
281
  credentials: 'include'
239
282
  });
@@ -255,10 +298,10 @@ export function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, c
255
298
  finally {
256
299
  setActionBusy(false);
257
300
  }
258
- }, [buildApiUrl, isAuthenticated, navigate]);
301
+ }, [buildCloudBillingUrl, isAuthenticated, navigate]);
259
302
  useEffect(() => {
260
- void loadBillingStatus();
261
- }, [loadBillingStatus]);
303
+ void loadAccountProfileSummary();
304
+ }, [loadAccountProfileSummary]);
262
305
  useEffect(() => {
263
306
  if (!hasResolvedActiveEntitlement)
264
307
  return;
@@ -276,11 +319,17 @@ export function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, c
276
319
  checkoutStartRan.current = true;
277
320
  setFeedback({ type: 'success', message: 'Checkout completed successfully. Finalizing your plan...' });
278
321
  void (async () => {
279
- const confirmed = querySessionId
322
+ const confirmation = querySessionId
280
323
  ? await confirmCheckoutSession(querySessionId)
281
- : false;
282
- if (!confirmed) {
283
- await loadBillingStatus();
324
+ : { confirmed: false, errorMessage: 'Checkout session is missing.' };
325
+ if (!confirmation.confirmed) {
326
+ const summary = await loadAccountProfileSummary({ force: true });
327
+ setFeedback({
328
+ type: 'error',
329
+ message: resolveRecoveryMessage(summary, confirmation.errorMessage)
330
+ });
331
+ scheduleTransientQueryClear();
332
+ return;
284
333
  }
285
334
  setFeedback({ type: 'success', message: 'Checkout completed successfully.' });
286
335
  scheduleTransientQueryClear();
@@ -288,8 +337,14 @@ export function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, c
288
337
  return;
289
338
  }
290
339
  if (checkoutState === 'cancel') {
291
- setFeedback({ type: 'error', message: 'Checkout was canceled. No charge was made.' });
292
- scheduleTransientQueryClear();
340
+ void (async () => {
341
+ const summary = await loadAccountProfileSummary({ force: true });
342
+ setFeedback({
343
+ type: 'error',
344
+ message: resolveRecoveryMessage(summary, 'Checkout was canceled. No charge was made.')
345
+ });
346
+ scheduleTransientQueryClear();
347
+ })();
293
348
  return;
294
349
  }
295
350
  if ((checkoutState === 'start' || checkoutState === 'free') && isAuthenticated && queryPlanVersionId && !checkoutStartRan.current) {
@@ -310,9 +365,13 @@ export function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, c
310
365
  return null;
311
366
  if (current.message === GATE_MESSAGES.missingEntitlement)
312
367
  return null;
368
+ if (current.message === BILLING_CONFLICT_MESSAGES.CHECKOUT_SESSION_EXPIRED)
369
+ return null;
370
+ if (current.message === BILLING_CONFLICT_MESSAGES.CHECKOUT_SESSION_SUPERSEDED)
371
+ return null;
313
372
  return current;
314
373
  });
315
- }, [checkoutState, confirmCheckoutSession, gateMessage, isAuthenticated, loadBillingStatus, queryInterval, queryPlanVersionId, querySessionId, scheduleTransientQueryClear, startCheckout]);
374
+ }, [checkoutState, confirmCheckoutSession, gateMessage, isAuthenticated, loadAccountProfileSummary, queryInterval, queryPlanVersionId, querySessionId, resolveRecoveryMessage, scheduleTransientQueryClear, startCheckout]);
316
375
  const handleSelectPlan = useCallback(async (intent) => {
317
376
  if (!isAuthenticated) {
318
377
  const nextCheckoutState = intent.pricingType === 'free' ? 'free' : 'start';
@@ -326,7 +385,7 @@ export function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, c
326
385
  return;
327
386
  }
328
387
  if (intent.isCurrentPlan) {
329
- if (!billingStatus?.stripeCustomerId) {
388
+ if (!accountProfileSummary?.stripeCustomerId) {
330
389
  setFeedback({ type: 'success', message: 'Your current plan does not use Stripe billing.' });
331
390
  return;
332
391
  }
@@ -350,7 +409,7 @@ export function PlansPage({ isAuthenticated, runtimeMode, authSessionResolved, c
350
409
  }
351
410
  navigate('/');
352
411
  }, [closeEmbeddedPlansScreen, embedded, navigate]);
353
- const topActions = embedded ? (_jsx(_Fragment, { children: isAuthenticated && billingStatus?.stripeCustomerId && (_jsx("button", { className: styles.navAction, onClick: () => { void openBillingPortal(); }, disabled: actionBusy, children: "Manage billing" })) })) : (_jsxs(_Fragment, { children: [_jsx("button", { className: styles.navAction, onClick: handleBack, children: "Back" }), isAuthenticated && billingStatus?.stripeCustomerId && (_jsx("button", { className: styles.navAction, onClick: () => { void openBillingPortal(); }, disabled: actionBusy, children: "Manage billing" })), !isAuthenticated && (_jsxs("button", { className: styles.navAction, onClick: () => navigate(`/login?mode=login&next=${encodeURIComponent(buildEmbeddedPlansPath())}`), children: [_jsx(User, { size: 16, style: { marginRight: '8px', verticalAlign: 'text-bottom' } }), "Sign In"] }))] }));
412
+ const topActions = embedded ? (_jsx(_Fragment, { children: isAuthenticated && accountProfileSummary?.stripeCustomerId && (_jsx("button", { className: styles.navAction, onClick: () => { void openBillingPortal(); }, disabled: actionBusy, children: "Manage billing" })) })) : (_jsxs(_Fragment, { children: [_jsx("button", { className: styles.navAction, onClick: handleBack, children: "Back" }), isAuthenticated && accountProfileSummary?.stripeCustomerId && (_jsx("button", { className: styles.navAction, onClick: () => { void openBillingPortal(); }, disabled: actionBusy, children: "Manage billing" })), !isAuthenticated && (_jsxs("button", { className: styles.navAction, onClick: () => navigate(`/login?mode=login&next=${encodeURIComponent(buildEmbeddedPlansPath())}`), children: [_jsx(User, { size: 16, style: { marginRight: '8px', verticalAlign: 'text-bottom' } }), "Sign In"] }))] }));
354
413
  const chrome = embedded ? null : (_jsx(AppShellHeader, { projectName: projectName, currentWorkspaceId: currentWorkspaceId, runtimeMode: runtimeMode === 'cloud' ? 'cloud' : 'local', theme: currentTheme, actions: topActions }));
355
- return (_jsx(PlanComparisonPage, { chrome: chrome, heading: "Subscription Plans", subtitle: "Manage your subscription and explore available features. Your current plan is highlighted below.", isAuthenticated: isAuthenticated, authSessionResolved: authSessionResolved, currentPlanId: String(billingStatus?.planId || queryPlanId || '').trim() || null, currentPlanVersionId: String(billingStatus?.planVersionId || queryPlanVersionId || '').trim() || null, currentEntitlementState: String(billingStatus?.entitlementState || '').trim() || null, canManageCurrentPlan: Boolean(billingStatus?.stripeCustomerId), pricingEndpoint: pricingEndpoints[0], statusBanner: feedback, onBack: handleBack, backLabel: embedded ? 'Back to Board' : 'Back to App', topActions: embedded ? topActions : null, shellOwnsScroll: shellOwnsScroll, showPageBackButton: !embedded, fallbackPricingSource: fallbackPricingSource, onSelectPlan: handleSelectPlan }));
414
+ return (_jsx(PlanComparisonPage, { chrome: chrome, heading: "Subscription Plans", subtitle: "Manage your subscription and explore available features. Your current plan is highlighted below.", isAuthenticated: isAuthenticated, authSessionResolved: authSessionResolved, currentPlanId: String(accountProfileSummary?.planId || queryPlanId || '').trim() || null, currentPlanVersionId: String(accountProfileSummary?.planVersionId || queryPlanVersionId || '').trim() || null, currentEntitlementState: String(accountProfileSummary?.entitlementState || '').trim() || null, canManageCurrentPlan: Boolean(accountProfileSummary?.stripeCustomerId), pricingEndpoint: pricingEndpoints[0], statusBanner: feedback, onBack: handleBack, backLabel: embedded ? 'Back to Board' : 'Back to App', topActions: embedded ? topActions : null, shellOwnsScroll: shellOwnsScroll, showPageBackButton: !embedded, fallbackPricingSource: fallbackPricingSource, onSelectPlan: handleSelectPlan }));
356
415
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,100 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { MemoryRouter } from 'react-router-dom';
5
+ import { PlansPage } from './PlansPage';
6
+ vi.mock('./PlanComparisonPage', () => ({
7
+ PlanComparisonPage: ({ heading, statusBanner }) => (_jsxs("div", { children: [_jsx("h1", { children: heading }), statusBanner?.message ? _jsx("div", { children: statusBanner.message }) : null] }))
8
+ }));
9
+ describe('PlansPage', () => {
10
+ afterEach(() => {
11
+ vi.unstubAllGlobals();
12
+ });
13
+ it('shows recovery messaging instead of false checkout success when confirmation fails', async () => {
14
+ const fetchMock = vi.fn()
15
+ .mockResolvedValueOnce({
16
+ ok: true,
17
+ json: async () => ({
18
+ planId: 'pro',
19
+ planVersionId: 'pro-v1',
20
+ entitlementState: 'checkout_pending',
21
+ gate: 'checkout_pending',
22
+ message: 'Complete checkout to continue.'
23
+ })
24
+ })
25
+ .mockResolvedValueOnce({
26
+ ok: false,
27
+ json: async () => ({
28
+ error: 'Checkout session has not completed yet.',
29
+ code: 'CHECKOUT_SESSION_INCOMPLETE'
30
+ })
31
+ })
32
+ .mockResolvedValueOnce({
33
+ ok: true,
34
+ json: async () => ({
35
+ planId: 'pro',
36
+ planVersionId: 'pro-v1',
37
+ entitlementState: 'checkout_pending',
38
+ gate: 'plan_selection_required',
39
+ message: 'Your previous checkout expired. Choose a plan to continue.',
40
+ planSelectionRequired: true
41
+ })
42
+ });
43
+ vi.stubGlobal('fetch', fetchMock);
44
+ 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 }) }));
45
+ expect(await screen.findByText('Checkout completed successfully. Finalizing your plan...')).toBeInTheDocument();
46
+ expect(await screen.findByText('Your previous checkout expired. Choose a plan to continue.')).toBeInTheDocument();
47
+ expect(screen.queryByText('Checkout completed successfully.')).not.toBeInTheDocument();
48
+ await waitFor(() => {
49
+ expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/taskforce/billing/checkout-session/confirm', expect.objectContaining({
50
+ method: 'POST'
51
+ }));
52
+ });
53
+ });
54
+ it('shows conflict-specific recovery messaging when the account lands on plans already blocked', async () => {
55
+ const fetchMock = vi.fn()
56
+ .mockResolvedValueOnce({
57
+ ok: true,
58
+ json: async () => ({
59
+ planId: 'pro',
60
+ planVersionId: 'pro-v1',
61
+ entitlementState: 'pending_plan_selection',
62
+ gate: 'plan_selection_required',
63
+ message: 'Choose a plan to continue.',
64
+ billingConflictCode: 'CHECKOUT_SESSION_SUPERSEDED',
65
+ planSelectionRequired: true
66
+ })
67
+ });
68
+ vi.stubGlobal('fetch', fetchMock);
69
+ render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u2", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path }) }));
70
+ expect(await screen.findByText('A newer checkout replaced your previous checkout. Choose a plan to continue.')).toBeInTheDocument();
71
+ });
72
+ it('shows conflict-specific recovery messaging after checkout cancel when the latest attempt expired', async () => {
73
+ const fetchMock = vi.fn()
74
+ .mockResolvedValueOnce({
75
+ ok: true,
76
+ json: async () => ({
77
+ planId: 'pro',
78
+ planVersionId: 'pro-v1',
79
+ entitlementState: 'checkout_pending',
80
+ gate: 'checkout_pending',
81
+ message: 'Complete checkout to continue.'
82
+ })
83
+ })
84
+ .mockResolvedValueOnce({
85
+ ok: true,
86
+ json: async () => ({
87
+ planId: 'pro',
88
+ planVersionId: 'pro-v1',
89
+ entitlementState: 'pending_plan_selection',
90
+ gate: 'plan_selection_required',
91
+ message: 'Choose a plan to continue.',
92
+ billingConflictCode: 'CHECKOUT_SESSION_EXPIRED',
93
+ planSelectionRequired: true
94
+ })
95
+ });
96
+ vi.stubGlobal('fetch', fetchMock);
97
+ render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&checkout=cancel'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u3", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path }) }));
98
+ expect(await screen.findByText('Your previous checkout expired. Choose a plan to continue.')).toBeInTheDocument();
99
+ });
100
+ });
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { type UseTaskforceResult } from '../../hooks/useTaskforce';
3
3
  export type StandaloneLayoutProps = UseTaskforceResult & {
4
+ resolveCloudAuthUrl?: (path: string) => string;
4
5
  onHeaderMouseDown?: (e: React.MouseEvent) => void;
5
6
  isDragging?: boolean;
6
7
  onClose?: () => void;