@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.
- package/dist/Taskforce.module.css +11 -0
- package/dist/TaskforceCore.js +59 -9
- package/dist/TaskforceCore.test.js +717 -14
- package/dist/components/task/TaskKanban.test.js +17 -0
- package/dist/components/views/PlansPage.d.ts +3 -1
- package/dist/components/views/PlansPage.js +110 -51
- package/dist/components/views/PlansPage.test.d.ts +1 -0
- package/dist/components/views/PlansPage.test.js +100 -0
- package/dist/components/views/StandaloneLayout.d.ts +1 -0
- package/dist/components/views/StandaloneLayout.js +106 -151
- package/dist/components/views/standalone/modals/AccountHubModal.d.ts +8 -3
- package/dist/components/views/standalone/modals/AccountHubModal.js +16 -2
- package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +1 -0
- package/dist/components/views/standalone/modals/AccountHubModal.test.js +26 -0
- package/dist/core/EntitlementsPolicy.test.js +2 -2
- package/dist/core/GlobalSettingsService.js +78 -18
- package/dist/core/PlanEntitlementService.d.ts +29 -5
- package/dist/core/PlanEntitlementService.js +297 -219
- package/dist/core/SignupMonetizationSettings.test.js +173 -49
- package/dist/core/SystemAdmin.test.js +186 -96
- package/dist/core/Taskforce.d.ts +51 -10
- package/dist/core/Taskforce.js +218 -49
- package/dist/core/Taskforce.mcpAuth.test.js +0 -2
- package/dist/core/UserProfileAvatarDrafts.test.js +12 -3
- package/dist/core/types.d.ts +11 -12
- package/dist/hooks/useSyncOrchestrator.js +17 -3
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +97 -5
- package/dist/hooks/useTaskforce.d.ts +3 -0
- package/dist/hooks/useTaskforce.js +9 -4
- package/dist/mcp/runtime.js +90 -29
- package/dist/mcp/runtime.test.js +272 -81
- package/dist/migrations/billingSchemaParity.test.js +3 -0
- package/dist/migrations/taskSchemaMigrations.js +15 -0
- package/dist/server/index.cookieProxy.test.js +1 -0
- package/dist/server/index.js +2 -0
- package/dist/server/index.test.js +2 -1
- package/dist/server/mockStripe.d.ts +1 -0
- package/dist/server/mockStripe.js +2 -0
- package/dist/server/routes/admin.js +80 -23
- package/dist/server/routes/auth.d.ts +1 -0
- package/dist/server/routes/auth.js +194 -53
- package/dist/server/routes/billing.js +506 -23
- package/dist/server/routes/billing.test.js +937 -45
- package/dist/server/routes/shared.js +5 -3
- package/dist/server/routes/sync.integration.test.js +160 -0
- package/dist/server/routes/sync.js +15 -5
- package/dist/server/routes.js +3 -3
- package/dist/server/routes.test.js +287 -76
- package/dist/sync/cloudSyncApi.d.ts +1 -0
- package/dist/sync/contentSyncState.d.ts +10 -0
- package/dist/sync/contentSyncState.js +277 -16
- package/dist/sync/syncService.d.ts +1 -0
- package/dist/sync/syncService.js +21 -8
- package/dist/sync/workspacePullFeed.d.ts +10 -0
- package/dist/sync/workspacePullFeed.js +111 -63
- package/dist/sync/workspacePullFeed.test.js +112 -0
- package/dist/ui/assets/{AgentsModule-JG5jc3he.js → AgentsModule-CpsTmrIW.js} +1 -1
- package/dist/ui/assets/{AnnotatedAttachmentWorkspace-Dvi02Y7q.js → AnnotatedAttachmentWorkspace-C_TmXZwA.js} +1 -1
- package/dist/ui/assets/{DocumentWorkspace-iys3zfca.js → DocumentWorkspace-C3GyzrWm.js} +1 -1
- package/dist/ui/assets/{InitiativesModule-DJvLJI3A.js → InitiativesModule-4-Rh2K2n.js} +1 -1
- package/dist/ui/assets/PlansPage-BP7AYOqr.js +1 -0
- package/dist/ui/assets/{TaskSettings-Bqd4cCzW.js → TaskSettings-BOC5F6Ag.js} +1 -1
- package/dist/ui/assets/{WorkflowsModule-BguHeQaV.js → WorkflowsModule-CGk9s3NJ.js} +1 -1
- package/dist/ui/assets/index-CLIMgR6g.js +6 -0
- package/dist/ui/assets/index-DneETxcu.css +1 -0
- package/dist/ui/assets/{vendor-icons-C8EED3Oh.js → vendor-icons-pWocEipT.js} +1 -1
- package/dist/ui/index.html +3 -3
- package/dist/utils/accountProfileSummaryCache.d.ts +31 -0
- package/dist/utils/accountProfileSummaryCache.js +97 -0
- package/dist/utils/accountProfileSummaryCache.test.d.ts +1 -0
- package/dist/utils/accountProfileSummaryCache.test.js +63 -0
- package/package.json +3 -3
- package/dist/ui/assets/PlansPage-BMPlpFpw.js +0 -1
- package/dist/ui/assets/index-MXeWoebC.css +0 -1
- package/dist/ui/assets/index-YJ4k5QNa.js +0 -6
- package/dist/utils/billingStatusCache.d.ts +0 -21
- 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 {
|
|
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
|
-
|
|
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 [
|
|
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(
|
|
93
|
+
const entitlementState = String(accountProfileSummary?.entitlementState || '').trim().toLowerCase();
|
|
86
94
|
return ACTIVE_ENTITLEMENT_STATES.has(entitlementState);
|
|
87
|
-
}, [
|
|
95
|
+
}, [accountProfileSummary?.entitlementState]);
|
|
88
96
|
const gateMessage = useMemo(() => {
|
|
89
97
|
if (hasResolvedActiveEntitlement)
|
|
90
98
|
return null;
|
|
91
|
-
|
|
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 (
|
|
110
|
+
if (effectiveGateState === 'checkout_pending')
|
|
111
|
+
return GATE_MESSAGES.checkoutPending;
|
|
112
|
+
if (effectiveGateState === 'misconfigured')
|
|
94
113
|
return GATE_MESSAGES.misconfigured;
|
|
95
|
-
if (
|
|
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
|
|
120
|
-
const
|
|
138
|
+
const loadAccountProfileSummary = useCallback(async (options) => {
|
|
139
|
+
const accountProfileSummaryUrl = buildCloudBillingUrl(ACCOUNT_PROFILE_SUMMARY_PATH);
|
|
121
140
|
if (!isAuthenticated) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return;
|
|
141
|
+
setAccountProfileSummary(null);
|
|
142
|
+
invalidateAccountProfileSummaryCache(accountProfileSummaryUrl, { identityKey: authUserId });
|
|
143
|
+
return null;
|
|
125
144
|
}
|
|
126
145
|
try {
|
|
127
|
-
const payload = await
|
|
128
|
-
|
|
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
|
-
}, [
|
|
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(
|
|
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
|
-
|
|
157
|
-
await
|
|
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
|
-
|
|
163
|
-
await
|
|
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
|
-
|
|
169
|
-
await
|
|
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
|
-
}, [
|
|
238
|
+
}, [authUserId, buildCloudBillingUrl, loadAccountProfileSummary, runtimeMode]);
|
|
204
239
|
const confirmCheckoutSession = useCallback(async (sessionId) => {
|
|
205
|
-
if (!sessionId)
|
|
206
|
-
return
|
|
240
|
+
if (!sessionId) {
|
|
241
|
+
return {
|
|
242
|
+
confirmed: false,
|
|
243
|
+
errorMessage: 'Checkout session is missing.'
|
|
244
|
+
};
|
|
245
|
+
}
|
|
207
246
|
try {
|
|
208
|
-
const res = await fetch(
|
|
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
|
-
|
|
217
|
-
|
|
255
|
+
return {
|
|
256
|
+
confirmed: false,
|
|
257
|
+
errorMessage: String(payload?.error || 'Unable to confirm checkout completion.')
|
|
258
|
+
};
|
|
218
259
|
}
|
|
219
|
-
|
|
220
|
-
await
|
|
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
|
-
|
|
225
|
-
|
|
265
|
+
return {
|
|
266
|
+
confirmed: false,
|
|
267
|
+
errorMessage: 'Unable to confirm checkout completion.'
|
|
268
|
+
};
|
|
226
269
|
}
|
|
227
|
-
}, [
|
|
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(
|
|
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
|
-
}, [
|
|
301
|
+
}, [buildCloudBillingUrl, isAuthenticated, navigate]);
|
|
259
302
|
useEffect(() => {
|
|
260
|
-
void
|
|
261
|
-
}, [
|
|
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
|
|
322
|
+
const confirmation = querySessionId
|
|
280
323
|
? await confirmCheckoutSession(querySessionId)
|
|
281
|
-
: false;
|
|
282
|
-
if (!confirmed) {
|
|
283
|
-
await
|
|
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
|
-
|
|
292
|
-
|
|
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,
|
|
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 (!
|
|
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 &&
|
|
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(
|
|
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;
|