@taskforcehq/taskforce 0.3.302 → 0.3.304
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 +113 -5
- package/dist/TaskforceCore.js +560 -208
- package/dist/TaskforceCore.test.js +1833 -91
- package/dist/annotatedAttachments/service.d.ts +1 -1
- package/dist/annotatedAttachments/types.d.ts +1 -1
- package/dist/components/features/AgentsModule.js +1 -1
- package/dist/components/features/AgentsModule.test.js +1 -1
- package/dist/components/features/TaskSettings.js +58 -38
- package/dist/components/features/TaskSettings.test.js +139 -40
- package/dist/components/task/TaskActionHeader.d.ts +2 -1
- package/dist/components/task/TaskActionHeader.js +3 -3
- package/dist/components/task/TaskActionHeader.test.js +8 -0
- package/dist/components/task/TaskCard.js +16 -1
- package/dist/components/task/TaskCard.test.js +31 -0
- package/dist/components/task/TaskForm.d.ts +2 -1
- package/dist/components/task/TaskForm.js +22 -4
- package/dist/components/task/TaskForm.test.js +46 -0
- package/dist/components/task/TaskKanban.d.ts +1 -1
- package/dist/components/task/TaskKanban.js +31 -160
- package/dist/components/task/TaskKanban.test.js +17 -0
- package/dist/components/task/TaskStatusActions.d.ts +6 -1
- package/dist/components/task/TaskStatusActions.js +8 -3
- package/dist/components/task/TaskStatusActions.test.js +9 -0
- package/dist/components/task/taskKanbanDropRules.d.ts +58 -0
- package/dist/components/task/taskKanbanDropRules.js +187 -0
- package/dist/components/task/taskKanbanDropRules.test.d.ts +1 -0
- package/dist/components/task/taskKanbanDropRules.test.js +114 -0
- package/dist/components/ui/Modal.d.ts +1 -1
- package/dist/components/ui/Modal.js +1 -0
- package/dist/components/views/AppShellHeader.js +5 -1
- package/dist/components/views/AppShellHeader.test.js +4 -0
- package/dist/components/views/PlanComparisonPage.d.ts +3 -2
- package/dist/components/views/PlanComparisonPage.js +4 -2
- package/dist/components/views/PlanComparisonPage.test.js +4 -0
- package/dist/components/views/PlansPage.d.ts +7 -1
- package/dist/components/views/PlansPage.js +139 -53
- package/dist/components/views/PlansPage.test.d.ts +1 -0
- package/dist/components/views/PlansPage.test.js +157 -0
- package/dist/components/views/StandaloneLayout.d.ts +1 -0
- package/dist/components/views/StandaloneLayout.js +216 -186
- package/dist/components/views/WidgetView.js +11 -2
- 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/components/views/standalone/modals/SyncStatusModal.js +1 -1
- package/dist/core/DeletedTaskLifecycleService.d.ts +2 -1
- package/dist/core/EntitlementsPolicy.test.js +2 -2
- package/dist/core/GlobalSettingsService.js +78 -18
- package/dist/core/PlanEntitlementService.d.ts +31 -5
- package/dist/core/PlanEntitlementService.js +350 -222
- package/dist/core/SignupMonetizationSettings.test.js +208 -49
- package/dist/core/SystemAdmin.test.js +186 -96
- package/dist/core/TaskActivityService.d.ts +4 -1
- package/dist/core/TaskActivityService.js +45 -0
- package/dist/core/TaskAuditTimeline.test.js +52 -1
- package/dist/core/Taskforce.d.ts +65 -20
- package/dist/core/Taskforce.js +261 -51
- package/dist/core/Taskforce.mcpAuth.test.js +0 -2
- package/dist/core/UserProfileAvatarDrafts.test.js +12 -3
- package/dist/core/WorkspacePlanMode.test.js +65 -0
- package/dist/core/types.d.ts +11 -12
- package/dist/documentReviews/service.d.ts +1 -1
- package/dist/documentReviews/types.d.ts +1 -1
- package/dist/hooks/auth/useTaskforceAuthBootstrap.d.ts +48 -0
- package/dist/hooks/auth/useTaskforceAuthBootstrap.js +219 -0
- package/dist/hooks/sync/auth.d.ts +37 -0
- package/dist/hooks/sync/auth.js +61 -0
- package/dist/hooks/sync/bootstrap.d.ts +34 -0
- package/dist/hooks/sync/bootstrap.js +52 -0
- package/dist/hooks/sync/orchestratorShared.d.ts +36 -0
- package/dist/hooks/sync/orchestratorShared.js +209 -0
- package/dist/hooks/sync/orchestratorShared.test.d.ts +1 -0
- package/dist/hooks/sync/orchestratorShared.test.js +49 -0
- package/dist/hooks/sync/realtime.d.ts +25 -0
- package/dist/hooks/sync/realtime.js +60 -0
- package/dist/hooks/sync/recovery.d.ts +69 -0
- package/dist/hooks/sync/recovery.js +118 -0
- package/dist/hooks/sync/transfers.d.ts +122 -0
- package/dist/hooks/sync/transfers.js +431 -0
- package/dist/hooks/sync/useDataVersionRefresh.d.ts +16 -0
- package/dist/hooks/sync/useDataVersionRefresh.js +73 -0
- package/dist/hooks/ui/useResourceExport.d.ts +3 -1
- package/dist/hooks/ui/useResourceExport.js +5 -2
- package/dist/hooks/ui/useWorkflowTemplates.js +11 -6
- package/dist/hooks/useRealtimeSync.js +2 -1
- package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +8 -0
- package/dist/hooks/useSyncOrchestrator.context-assets.test.d.ts +1 -0
- package/dist/hooks/useSyncOrchestrator.context-assets.test.js +228 -0
- package/dist/hooks/useSyncOrchestrator.d.ts +5 -4
- package/dist/hooks/useSyncOrchestrator.js +315 -860
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +665 -8
- package/dist/hooks/useTaskMutations.js +45 -6
- package/dist/hooks/useTaskMutations.test.js +147 -0
- package/dist/hooks/useTaskforce.d.ts +11 -2
- package/dist/hooks/useTaskforce.js +290 -812
- package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
- package/dist/hooks/useWorkspaceSyncController.js +22 -10
- package/dist/hooks/workspace/useTaskforceApiRouting.test.js +17 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +166 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +434 -0
- package/dist/hooks/workspace/workspaceLocalState.d.ts +19 -0
- package/dist/hooks/workspace/workspaceLocalState.js +100 -0
- package/dist/hooks/workspace/workspaceLocalState.test.d.ts +1 -0
- package/dist/hooks/workspace/workspaceLocalState.test.js +59 -0
- package/dist/localization/locales/en-US.js +2 -2
- package/dist/mcp/canonicalAssetHelpers.d.ts +70 -0
- package/dist/mcp/canonicalAssetHelpers.js +259 -0
- package/dist/mcp/clientIntegrations.d.ts +12 -1
- package/dist/mcp/clientIntegrations.js +161 -6
- package/dist/mcp/clientIntegrations.test.js +76 -4
- package/dist/mcp/documentHelpers.d.ts +100 -0
- package/dist/mcp/documentHelpers.js +161 -0
- package/dist/mcp/runtime.js +264 -505
- package/dist/mcp/runtime.test.js +353 -89
- package/dist/mcp/taskAttachmentHelpers.d.ts +41 -0
- package/dist/mcp/taskAttachmentHelpers.js +60 -0
- package/dist/mcp/taskAttachmentTypes.d.ts +37 -0
- package/dist/mcp/taskAttachmentTypes.js +1 -0
- package/dist/migrations/billingSchemaParity.test.js +3 -0
- package/dist/migrations/taskSchemaMigrations.js +15 -0
- package/dist/plugins/vite.js +1 -1
- package/dist/runtime/appGateDefinitions.d.ts +1 -1
- package/dist/runtime/appGateDefinitions.js +1 -1
- package/dist/server/index.cookieProxy.test.js +1 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +48 -3
- package/dist/server/index.test.js +14 -2
- package/dist/server/mockStripe.d.ts +1 -0
- package/dist/server/mockStripe.js +2 -0
- package/dist/server/routes/admin.js +91 -40
- package/dist/server/routes/auth.d.ts +1 -0
- package/dist/server/routes/auth.js +231 -55
- package/dist/server/routes/billing.js +473 -39
- package/dist/server/routes/billing.test.js +972 -37
- package/dist/server/routes/shared.d.ts +30 -0
- package/dist/server/routes/shared.js +31 -3
- package/dist/server/routes/sync.integration.test.js +180 -0
- package/dist/server/routes/sync.js +125 -242
- package/dist/server/routes/tasks.js +49 -7
- package/dist/server/routes.js +11 -22
- package/dist/server/routes.test.js +530 -81
- package/dist/shared/taskActor.d.ts +10 -0
- package/dist/shared/taskActor.js +1 -0
- package/dist/sync/cloudSyncApi.d.ts +1 -0
- package/dist/sync/collaborationSyncPayload.js +4 -0
- package/dist/sync/collaborationSyncPayload.test.js +8 -0
- package/dist/sync/collaborationSyncState.d.ts +3 -1
- package/dist/sync/collaborationSyncState.js +19 -0
- package/dist/sync/contentSyncState.d.ts +10 -0
- package/dist/sync/contentSyncState.js +277 -14
- package/dist/sync/syncApplyHandlers.d.ts +87 -1
- package/dist/sync/syncApplyHandlers.js +370 -8
- package/dist/sync/syncApplyHandlers.test.d.ts +1 -0
- package/dist/sync/syncApplyHandlers.test.js +377 -0
- package/dist/sync/syncService.d.ts +9 -1
- package/dist/sync/syncService.js +57 -10
- package/dist/sync/syncService.test.js +48 -0
- package/dist/sync/workspacePullFeed.d.ts +11 -0
- package/dist/sync/workspacePullFeed.js +123 -68
- package/dist/sync/workspacePullFeed.test.js +180 -0
- package/dist/sync/workspaceSyncModel.d.ts +22 -1
- package/dist/sync/workspaceSyncModel.js +65 -0
- package/dist/sync/workspaceSyncModel.test.js +119 -0
- package/dist/types.d.ts +2 -10
- package/dist/ui/assets/AgentsModule-BLWVbuKP.js +1 -0
- package/dist/ui/assets/{AnnotatedAttachmentWorkspace-Dvi02Y7q.js → AnnotatedAttachmentWorkspace-BlS-cqnl.js} +1 -1
- package/dist/ui/assets/{DocumentWorkspace-iys3zfca.js → DocumentWorkspace-BH_6DF6x.js} +1 -1
- package/dist/ui/assets/{InitiativesModule-DJvLJI3A.js → InitiativesModule-BjR6iBf9.js} +1 -1
- package/dist/ui/assets/{PlansPage-BlVC_lRq.css → PlansPage-C2dd2n1X.css} +1 -1
- package/dist/ui/assets/PlansPage-CSDQ4sLa.js +1 -0
- package/dist/ui/assets/TaskSettings-CEDy34Jo.js +9 -0
- package/dist/ui/assets/{WorkflowsModule-BguHeQaV.js → WorkflowsModule-DnFqdUME.js} +1 -1
- package/dist/ui/assets/index-DXUdtH1B.js +6 -0
- package/dist/ui/assets/index-Ii0B0rTO.css +1 -0
- package/dist/ui/assets/{vendor-icons-C8EED3Oh.js → vendor-icons-I7ZwG1z_.js} +1 -1
- package/dist/ui/index.html +4 -4
- 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/dist/utils/taskActivity.js +4 -0
- package/dist/utils/taskActivity.test.js +25 -1
- package/package.json +5 -3
- package/scripts/playwright-auth.sh +4 -0
- package/dist/ui/assets/AgentsModule-JG5jc3he.js +0 -1
- package/dist/ui/assets/PlansPage-BMPlpFpw.js +0 -1
- package/dist/ui/assets/TaskSettings-Bqd4cCzW.js +0 -8
- 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
|
@@ -5,8 +5,20 @@ import { registerBillingRoutes } from './billing';
|
|
|
5
5
|
const mockConstructEvent = vi.fn();
|
|
6
6
|
const mockPricesList = vi.fn(async () => ({ data: [], has_more: false }));
|
|
7
7
|
const mockPriceRetrieve = vi.fn(async (id) => ({ id, active: true, currency: 'usd', unit_amount: 1500, recurring: { interval: 'month' } }));
|
|
8
|
-
const mockCheckoutCreate = vi.fn(async () => ({
|
|
9
|
-
|
|
8
|
+
const mockCheckoutCreate = vi.fn(async () => ({
|
|
9
|
+
id: 'cs_test',
|
|
10
|
+
url: 'https://example.test/checkout',
|
|
11
|
+
expires_at: Math.floor(Date.now() / 1000) + (24 * 60 * 60)
|
|
12
|
+
}));
|
|
13
|
+
const mockCheckoutRetrieve = vi.fn(async (id) => ({
|
|
14
|
+
id,
|
|
15
|
+
mode: 'subscription',
|
|
16
|
+
status: 'complete',
|
|
17
|
+
payment_status: 'paid',
|
|
18
|
+
customer: 'cus_123',
|
|
19
|
+
subscription: 'sub_123',
|
|
20
|
+
metadata: { userId: 'u1' }
|
|
21
|
+
}));
|
|
10
22
|
const mockPortalCreate = vi.fn(async () => ({ id: 'bps_test', url: 'https://example.test/portal' }));
|
|
11
23
|
const mockSubscriptionCancel = vi.fn(async () => ({ id: 'sub_test', status: 'canceled' }));
|
|
12
24
|
const mockSubscriptionList = vi.fn(async () => ({ data: [] }));
|
|
@@ -130,6 +142,17 @@ function setupBillingSchema(db) {
|
|
|
130
142
|
purge_after TEXT NOT NULL,
|
|
131
143
|
created_at TEXT NOT NULL
|
|
132
144
|
);
|
|
145
|
+
CREATE TABLE billing_checkout_attempts (
|
|
146
|
+
session_id TEXT PRIMARY KEY,
|
|
147
|
+
tenant_id TEXT NOT NULL,
|
|
148
|
+
user_id TEXT NOT NULL,
|
|
149
|
+
plan_version_id TEXT NOT NULL,
|
|
150
|
+
billing_interval TEXT,
|
|
151
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
152
|
+
expires_at TEXT,
|
|
153
|
+
created_at TEXT NOT NULL,
|
|
154
|
+
updated_at TEXT NOT NULL
|
|
155
|
+
);
|
|
133
156
|
CREATE TABLE billing_price_mappings (
|
|
134
157
|
tenant_id TEXT NOT NULL,
|
|
135
158
|
plan_version_id TEXT NOT NULL,
|
|
@@ -188,6 +211,18 @@ function setupBillingSchema(db) {
|
|
|
188
211
|
function buildBillingRouteHandlers(db, opts) {
|
|
189
212
|
const routes = new Map();
|
|
190
213
|
const tenantId = 'default';
|
|
214
|
+
const resolvePlanIdForVersion = (planVersionIdRaw) => {
|
|
215
|
+
const planVersionId = String(planVersionIdRaw || '').trim();
|
|
216
|
+
if (!planVersionId)
|
|
217
|
+
return '';
|
|
218
|
+
const row = db.prepare(`
|
|
219
|
+
SELECT plan_id
|
|
220
|
+
FROM plan_versions
|
|
221
|
+
WHERE tenant_id = ? AND plan_version_id = ?
|
|
222
|
+
LIMIT 1
|
|
223
|
+
`).get(tenantId, planVersionId);
|
|
224
|
+
return String(row?.plan_id || '').trim();
|
|
225
|
+
};
|
|
191
226
|
const core = {
|
|
192
227
|
getDatabaseAdapter: () => db,
|
|
193
228
|
getTenantId: () => tenantId,
|
|
@@ -200,6 +235,37 @@ function buildBillingRouteHandlers(db, opts) {
|
|
|
200
235
|
ON CONFLICT (user_id) DO NOTHING
|
|
201
236
|
`).run(userId, tenantId, now, now);
|
|
202
237
|
},
|
|
238
|
+
transitionCanonicalCommercialState: (input) => {
|
|
239
|
+
const now = new Date().toISOString();
|
|
240
|
+
const planVersionId = String(input.planVersionId || '').trim();
|
|
241
|
+
const effectiveAt = input.effectiveAt || now;
|
|
242
|
+
const effectiveUntil = input.effectiveUntil || null;
|
|
243
|
+
db.prepare(`
|
|
244
|
+
INSERT INTO account_entitlements (
|
|
245
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
|
|
246
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
247
|
+
ON CONFLICT (account_id) DO UPDATE SET
|
|
248
|
+
plan_version_id = excluded.plan_version_id,
|
|
249
|
+
entitlement_state = excluded.entitlement_state,
|
|
250
|
+
effective_at = excluded.effective_at,
|
|
251
|
+
effective_until = excluded.effective_until,
|
|
252
|
+
updated_by = excluded.updated_by,
|
|
253
|
+
updated_at = excluded.updated_at
|
|
254
|
+
`).run(input.accountId, tenantId, planVersionId, input.state, effectiveAt, effectiveUntil, input.updatedBy, input.createdAt || now, input.updatedAt || now);
|
|
255
|
+
const planId = typeof input.billingMirrorPlanId === 'string'
|
|
256
|
+
? input.billingMirrorPlanId
|
|
257
|
+
: resolvePlanIdForVersion(planVersionId);
|
|
258
|
+
db.prepare(`
|
|
259
|
+
INSERT INTO user_billing (
|
|
260
|
+
user_id, tenant_id, tier, entitlement_state, effective_until, created_at, updated_at
|
|
261
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
262
|
+
ON CONFLICT (user_id) DO UPDATE SET
|
|
263
|
+
tier = excluded.tier,
|
|
264
|
+
entitlement_state = excluded.entitlement_state,
|
|
265
|
+
effective_until = excluded.effective_until,
|
|
266
|
+
updated_at = excluded.updated_at
|
|
267
|
+
`).run(input.accountId, tenantId, planId, input.state, effectiveUntil, now, now);
|
|
268
|
+
},
|
|
203
269
|
listPlanCatalog: () => [
|
|
204
270
|
{ planId: 'pro', displayName: 'Pro Plan', status: 'active', createdAt: '', updatedAt: '' },
|
|
205
271
|
{ planId: 'inactive-plan', displayName: 'Inactive', status: 'archived', createdAt: '', updatedAt: '' },
|
|
@@ -228,23 +294,25 @@ function buildBillingRouteHandlers(db, opts) {
|
|
|
228
294
|
};
|
|
229
295
|
});
|
|
230
296
|
},
|
|
231
|
-
getAccountEntitlement: () =>
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
297
|
+
getAccountEntitlement: () => {
|
|
298
|
+
const entitlement = opts?.accessStatus?.entitlement;
|
|
299
|
+
return {
|
|
300
|
+
accountId: String(entitlement?.accountId || 'u1'),
|
|
301
|
+
planVersionId: String(entitlement?.planVersionId || 'pro-v1'),
|
|
302
|
+
planId: String(entitlement?.planId || 'pro'),
|
|
303
|
+
state: String(entitlement?.state || 'active'),
|
|
304
|
+
effectiveAt: entitlement?.effectiveAt ?? null,
|
|
305
|
+
effectiveUntil: entitlement?.effectiveUntil ?? null,
|
|
306
|
+
updatedBy: null,
|
|
307
|
+
createdAt: '',
|
|
308
|
+
updatedAt: ''
|
|
309
|
+
};
|
|
310
|
+
},
|
|
311
|
+
getAccountAccessStatus: () => opts?.accessStatus || ({
|
|
243
312
|
gate: 'ok',
|
|
244
313
|
hasEntitlement: true,
|
|
245
314
|
canAccessApp: true,
|
|
246
315
|
canAccessPlans: true,
|
|
247
|
-
signupMonetizationStrategy: 'auto_assign_default',
|
|
248
316
|
planSelectionRequired: false,
|
|
249
317
|
message: null,
|
|
250
318
|
entitlement: {
|
|
@@ -421,6 +489,286 @@ describe('Billing Webhook Routes', () => {
|
|
|
421
489
|
`).get();
|
|
422
490
|
expect(failure.reason).toContain('Unknown price mapping');
|
|
423
491
|
});
|
|
492
|
+
it('keeps blocked accounts in checkout_pending when Stripe reports an incomplete subscription', async () => {
|
|
493
|
+
const now = new Date().toISOString();
|
|
494
|
+
db.prepare(`
|
|
495
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
496
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
497
|
+
`).run(now, now);
|
|
498
|
+
db.prepare(`
|
|
499
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
500
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
501
|
+
`).run(now, now);
|
|
502
|
+
db.prepare(`
|
|
503
|
+
INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
|
|
504
|
+
VALUES ('u1', 'default', 'cus_123', 'checkout_pending', ?, ?)
|
|
505
|
+
`).run(now, now);
|
|
506
|
+
db.prepare(`
|
|
507
|
+
INSERT INTO account_entitlements (
|
|
508
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
509
|
+
) VALUES ('u1', 'default', 'pro-v1', 'checkout_pending', ?, 'checkout-start', ?, ?)
|
|
510
|
+
`).run(now, now, now);
|
|
511
|
+
mockConstructEvent.mockReturnValue({
|
|
512
|
+
id: 'evt_incomplete_subscription',
|
|
513
|
+
type: 'customer.subscription.updated',
|
|
514
|
+
data: {
|
|
515
|
+
object: {
|
|
516
|
+
id: 'sub_123',
|
|
517
|
+
customer: 'cus_123',
|
|
518
|
+
status: 'incomplete',
|
|
519
|
+
trial_end: null,
|
|
520
|
+
metadata: { planVersionId: 'pro-v1' },
|
|
521
|
+
items: {
|
|
522
|
+
data: [{
|
|
523
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
524
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
525
|
+
}]
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
const req = createMockReq('{"id":"evt_incomplete_subscription"}', {
|
|
531
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
532
|
+
'stripe-signature': 'sig'
|
|
533
|
+
});
|
|
534
|
+
const res = createMockRes();
|
|
535
|
+
await webhookHandler(req, res, {});
|
|
536
|
+
expect(res.statusCode).toBe(200);
|
|
537
|
+
const entitlement = db.prepare(`
|
|
538
|
+
SELECT plan_version_id, entitlement_state, updated_by
|
|
539
|
+
FROM account_entitlements
|
|
540
|
+
WHERE account_id = 'u1' AND tenant_id = 'default'
|
|
541
|
+
LIMIT 1
|
|
542
|
+
`).get();
|
|
543
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
544
|
+
plan_version_id: 'pro-v1',
|
|
545
|
+
entitlement_state: 'checkout_pending',
|
|
546
|
+
updated_by: 'stripe-webhook'
|
|
547
|
+
}));
|
|
548
|
+
const billing = db.prepare(`
|
|
549
|
+
SELECT entitlement_state, stripe_status, stripe_subscription_id
|
|
550
|
+
FROM user_billing
|
|
551
|
+
WHERE user_id = 'u1' AND tenant_id = 'default'
|
|
552
|
+
LIMIT 1
|
|
553
|
+
`).get();
|
|
554
|
+
expect(billing).toEqual(expect.objectContaining({
|
|
555
|
+
entitlement_state: 'checkout_pending',
|
|
556
|
+
stripe_status: 'incomplete',
|
|
557
|
+
stripe_subscription_id: 'sub_123'
|
|
558
|
+
}));
|
|
559
|
+
});
|
|
560
|
+
it('preserves active access when Stripe reports an incomplete subscription for an already-active account', async () => {
|
|
561
|
+
const now = new Date().toISOString();
|
|
562
|
+
db.prepare(`
|
|
563
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
564
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
565
|
+
`).run(now, now);
|
|
566
|
+
db.prepare(`
|
|
567
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
568
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
569
|
+
`).run(now, now);
|
|
570
|
+
db.prepare(`
|
|
571
|
+
INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
|
|
572
|
+
VALUES ('u1', 'default', 'cus_123', 'active', ?, ?)
|
|
573
|
+
`).run(now, now);
|
|
574
|
+
db.prepare(`
|
|
575
|
+
INSERT INTO account_entitlements (
|
|
576
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
577
|
+
) VALUES ('u1', 'default', 'pro-v1', 'active', ?, 'stripe-webhook', ?, ?)
|
|
578
|
+
`).run(now, now, now);
|
|
579
|
+
mockConstructEvent.mockReturnValue({
|
|
580
|
+
id: 'evt_incomplete_active_account',
|
|
581
|
+
type: 'customer.subscription.updated',
|
|
582
|
+
data: {
|
|
583
|
+
object: {
|
|
584
|
+
id: 'sub_123',
|
|
585
|
+
customer: 'cus_123',
|
|
586
|
+
status: 'incomplete',
|
|
587
|
+
trial_end: null,
|
|
588
|
+
metadata: { planVersionId: 'pro-v1' },
|
|
589
|
+
items: {
|
|
590
|
+
data: [{
|
|
591
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
592
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
593
|
+
}]
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
const req = createMockReq('{"id":"evt_incomplete_active_account"}', {
|
|
599
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
600
|
+
'stripe-signature': 'sig'
|
|
601
|
+
});
|
|
602
|
+
const res = createMockRes();
|
|
603
|
+
await webhookHandler(req, res, {});
|
|
604
|
+
expect(res.statusCode).toBe(200);
|
|
605
|
+
const entitlement = db.prepare(`
|
|
606
|
+
SELECT entitlement_state, updated_by
|
|
607
|
+
FROM account_entitlements
|
|
608
|
+
WHERE account_id = 'u1' AND tenant_id = 'default'
|
|
609
|
+
LIMIT 1
|
|
610
|
+
`).get();
|
|
611
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
612
|
+
entitlement_state: 'active',
|
|
613
|
+
updated_by: 'stripe-webhook'
|
|
614
|
+
}));
|
|
615
|
+
const billing = db.prepare(`
|
|
616
|
+
SELECT entitlement_state, stripe_status
|
|
617
|
+
FROM user_billing
|
|
618
|
+
WHERE user_id = 'u1' AND tenant_id = 'default'
|
|
619
|
+
LIMIT 1
|
|
620
|
+
`).get();
|
|
621
|
+
expect(billing).toEqual(expect.objectContaining({
|
|
622
|
+
entitlement_state: 'active',
|
|
623
|
+
stripe_status: 'incomplete'
|
|
624
|
+
}));
|
|
625
|
+
});
|
|
626
|
+
it('ignores superseded subscription webhook events for a stale checkout plan selection', async () => {
|
|
627
|
+
const now = new Date().toISOString();
|
|
628
|
+
db.prepare(`
|
|
629
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
630
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
631
|
+
`).run(now, now);
|
|
632
|
+
db.prepare(`
|
|
633
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
634
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
635
|
+
`).run(now, now);
|
|
636
|
+
db.prepare(`
|
|
637
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
638
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
639
|
+
`).run(now, now);
|
|
640
|
+
db.prepare(`
|
|
641
|
+
INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
|
|
642
|
+
VALUES ('u1', 'default', 'cus_123', 'checkout_pending', ?, ?)
|
|
643
|
+
`).run(now, now);
|
|
644
|
+
db.prepare(`
|
|
645
|
+
INSERT INTO account_entitlements (
|
|
646
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
|
|
647
|
+
) VALUES ('u1', 'default', 'team-v1', 'checkout_pending', ?, ?, 'checkout-start', ?, ?)
|
|
648
|
+
`).run(now, new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
|
|
649
|
+
mockConstructEvent.mockReturnValue({
|
|
650
|
+
id: 'evt_stale_checkout_superseded',
|
|
651
|
+
type: 'customer.subscription.updated',
|
|
652
|
+
data: {
|
|
653
|
+
object: {
|
|
654
|
+
id: 'sub_123',
|
|
655
|
+
customer: 'cus_123',
|
|
656
|
+
status: 'active',
|
|
657
|
+
trial_end: null,
|
|
658
|
+
metadata: { planVersionId: 'pro-v1' },
|
|
659
|
+
items: {
|
|
660
|
+
data: [{
|
|
661
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
662
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
663
|
+
}]
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
const req = createMockReq('{"id":"evt_stale_checkout_superseded"}', {
|
|
669
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
670
|
+
'stripe-signature': 'sig'
|
|
671
|
+
});
|
|
672
|
+
const res = createMockRes();
|
|
673
|
+
await webhookHandler(req, res, {});
|
|
674
|
+
expect(res.statusCode).toBe(200);
|
|
675
|
+
const entitlement = db.prepare(`
|
|
676
|
+
SELECT plan_version_id, entitlement_state
|
|
677
|
+
FROM account_entitlements
|
|
678
|
+
WHERE account_id = 'u1' AND tenant_id = 'default'
|
|
679
|
+
LIMIT 1
|
|
680
|
+
`).get();
|
|
681
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
682
|
+
plan_version_id: 'team-v1',
|
|
683
|
+
entitlement_state: 'checkout_pending'
|
|
684
|
+
}));
|
|
685
|
+
const failure = db.prepare(`
|
|
686
|
+
SELECT reason
|
|
687
|
+
FROM stripe_webhook_failures
|
|
688
|
+
WHERE event_id = 'evt_stale_checkout_superseded'
|
|
689
|
+
ORDER BY created_at DESC
|
|
690
|
+
LIMIT 1
|
|
691
|
+
`).get();
|
|
692
|
+
expect(String(failure?.reason || '')).toContain('no longer current for this account');
|
|
693
|
+
});
|
|
694
|
+
it('ignores stale subscription webhooks for active accounts when a newer checkout attempt is open', async () => {
|
|
695
|
+
const now = new Date().toISOString();
|
|
696
|
+
db.prepare(`
|
|
697
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
698
|
+
VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
|
|
699
|
+
`).run(now, now);
|
|
700
|
+
db.prepare(`
|
|
701
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
702
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
703
|
+
`).run(now, now);
|
|
704
|
+
db.prepare(`
|
|
705
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
706
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
707
|
+
`).run(now, now);
|
|
708
|
+
db.prepare(`
|
|
709
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
710
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
711
|
+
`).run(now, now);
|
|
712
|
+
db.prepare(`
|
|
713
|
+
INSERT INTO user_billing (
|
|
714
|
+
user_id, tenant_id, stripe_customer_id, tier, entitlement_state, created_at, updated_at
|
|
715
|
+
) VALUES ('u1', 'default', 'cus_123', 'starter', 'active', ?, ?)
|
|
716
|
+
`).run(now, now);
|
|
717
|
+
db.prepare(`
|
|
718
|
+
INSERT INTO account_entitlements (
|
|
719
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
720
|
+
) VALUES ('u1', 'default', 'starter-v1', 'active', ?, 'free-plan-selection', ?, ?)
|
|
721
|
+
`).run(now, now, now);
|
|
722
|
+
db.prepare(`
|
|
723
|
+
INSERT INTO billing_checkout_attempts (
|
|
724
|
+
session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
725
|
+
) VALUES ('cs_newer', 'default', 'u1', 'team-v1', 'month', 'open', ?, ?, ?)
|
|
726
|
+
`).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
|
|
727
|
+
mockConstructEvent.mockReturnValue({
|
|
728
|
+
id: 'evt_active_stale_checkout',
|
|
729
|
+
type: 'customer.subscription.updated',
|
|
730
|
+
data: {
|
|
731
|
+
object: {
|
|
732
|
+
id: 'sub_stale',
|
|
733
|
+
customer: 'cus_123',
|
|
734
|
+
status: 'active',
|
|
735
|
+
trial_end: null,
|
|
736
|
+
metadata: { planVersionId: 'pro-v1' },
|
|
737
|
+
items: {
|
|
738
|
+
data: [{
|
|
739
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
740
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
741
|
+
}]
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
const req = createMockReq('{"id":"evt_active_stale_checkout"}', {
|
|
747
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
748
|
+
'stripe-signature': 'sig'
|
|
749
|
+
});
|
|
750
|
+
const res = createMockRes();
|
|
751
|
+
await webhookHandler(req, res, {});
|
|
752
|
+
expect(res.statusCode).toBe(200);
|
|
753
|
+
const entitlement = db.prepare(`
|
|
754
|
+
SELECT plan_version_id, entitlement_state
|
|
755
|
+
FROM account_entitlements
|
|
756
|
+
WHERE account_id = 'u1' AND tenant_id = 'default'
|
|
757
|
+
LIMIT 1
|
|
758
|
+
`).get();
|
|
759
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
760
|
+
plan_version_id: 'starter-v1',
|
|
761
|
+
entitlement_state: 'active'
|
|
762
|
+
}));
|
|
763
|
+
const failure = db.prepare(`
|
|
764
|
+
SELECT reason
|
|
765
|
+
FROM stripe_webhook_failures
|
|
766
|
+
WHERE event_id = 'evt_active_stale_checkout'
|
|
767
|
+
ORDER BY created_at DESC
|
|
768
|
+
LIMIT 1
|
|
769
|
+
`).get();
|
|
770
|
+
expect(String(failure?.reason || '')).toContain('no longer current for this account');
|
|
771
|
+
});
|
|
424
772
|
it('purges expired webhook failures during webhook processing', async () => {
|
|
425
773
|
const past = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
|
|
426
774
|
const now = new Date().toISOString();
|
|
@@ -558,7 +906,7 @@ describe('Billing Webhook Routes', () => {
|
|
|
558
906
|
expect(args.customer).toBe('cus_existing');
|
|
559
907
|
expect(args.customer_email).toBeUndefined();
|
|
560
908
|
});
|
|
561
|
-
it('
|
|
909
|
+
it('records checkout_pending when a blocked account starts a paid checkout', async () => {
|
|
562
910
|
const now = new Date().toISOString();
|
|
563
911
|
db.prepare(`
|
|
564
912
|
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
@@ -568,10 +916,11 @@ describe('Billing Webhook Routes', () => {
|
|
|
568
916
|
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
569
917
|
VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
|
|
570
918
|
`).run(now, now);
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
919
|
+
db.prepare(`
|
|
920
|
+
INSERT INTO account_entitlements (
|
|
921
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
|
|
922
|
+
) VALUES ('u1', 'default', 'starter-v1', 'pending_plan_selection', ?, ?, ?)
|
|
923
|
+
`).run(now, now, now);
|
|
575
924
|
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
576
925
|
const req = createMockReq(JSON.stringify({
|
|
577
926
|
planVersionId: 'collab-v1',
|
|
@@ -583,10 +932,178 @@ describe('Billing Webhook Routes', () => {
|
|
|
583
932
|
const res = createMockRes();
|
|
584
933
|
await checkoutHandler(req, res, {});
|
|
585
934
|
expect(res.statusCode).toBe(200);
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
935
|
+
expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
|
|
936
|
+
checkoutState: 'checkout',
|
|
937
|
+
id: 'cs_test'
|
|
938
|
+
}));
|
|
939
|
+
const entitlement = db.prepare(`
|
|
940
|
+
SELECT plan_version_id, entitlement_state, updated_by
|
|
941
|
+
FROM account_entitlements
|
|
942
|
+
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
943
|
+
LIMIT 1
|
|
944
|
+
`).get();
|
|
945
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
946
|
+
plan_version_id: 'collab-v1',
|
|
947
|
+
entitlement_state: 'checkout_pending',
|
|
948
|
+
updated_by: 'checkout-start'
|
|
949
|
+
}));
|
|
950
|
+
const billing = db.prepare(`
|
|
951
|
+
SELECT tier, entitlement_state, effective_until
|
|
952
|
+
FROM user_billing
|
|
953
|
+
WHERE tenant_id = 'default' AND user_id = 'u1'
|
|
954
|
+
LIMIT 1
|
|
955
|
+
`).get();
|
|
956
|
+
expect(billing).toEqual(expect.objectContaining({
|
|
957
|
+
tier: 'collab',
|
|
958
|
+
entitlement_state: 'checkout_pending'
|
|
959
|
+
}));
|
|
960
|
+
expect(typeof billing?.effective_until).toBe('string');
|
|
961
|
+
});
|
|
962
|
+
it('does not downgrade an active account to checkout_pending when starting paid checkout', async () => {
|
|
963
|
+
const now = new Date().toISOString();
|
|
964
|
+
db.prepare(`
|
|
965
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
966
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
967
|
+
`).run(now, now);
|
|
968
|
+
db.prepare(`
|
|
969
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
970
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
971
|
+
`).run(now, now);
|
|
972
|
+
db.prepare(`
|
|
973
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
974
|
+
VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
|
|
975
|
+
`).run(now, now);
|
|
976
|
+
db.prepare(`
|
|
977
|
+
INSERT INTO account_entitlements (
|
|
978
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
979
|
+
) VALUES ('u1', 'default', 'pro-v1', 'active', ?, 'stripe-webhook', ?, ?)
|
|
980
|
+
`).run(now, now, now);
|
|
981
|
+
db.prepare(`
|
|
982
|
+
INSERT INTO user_billing (
|
|
983
|
+
user_id, tenant_id, stripe_customer_id, tier, entitlement_state, created_at, updated_at
|
|
984
|
+
) VALUES ('u1', 'default', 'cus_existing', 'pro', 'active', ?, ?)
|
|
985
|
+
`).run(now, now);
|
|
986
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
987
|
+
const req = createMockReq(JSON.stringify({
|
|
988
|
+
planVersionId: 'team-v1',
|
|
989
|
+
interval: 'month'
|
|
990
|
+
}), {
|
|
991
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
992
|
+
host: 'app.taskforcehq.ai'
|
|
993
|
+
});
|
|
994
|
+
const res = createMockRes();
|
|
995
|
+
await checkoutHandler(req, res, {});
|
|
996
|
+
expect(res.statusCode).toBe(200);
|
|
997
|
+
const entitlement = db.prepare(`
|
|
998
|
+
SELECT plan_version_id, entitlement_state, updated_by
|
|
999
|
+
FROM account_entitlements
|
|
1000
|
+
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
1001
|
+
LIMIT 1
|
|
1002
|
+
`).get();
|
|
1003
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
1004
|
+
plan_version_id: 'pro-v1',
|
|
1005
|
+
entitlement_state: 'active',
|
|
1006
|
+
updated_by: 'stripe-webhook'
|
|
1007
|
+
}));
|
|
1008
|
+
const billing = db.prepare(`
|
|
1009
|
+
SELECT tier, entitlement_state
|
|
1010
|
+
FROM user_billing
|
|
1011
|
+
WHERE tenant_id = 'default' AND user_id = 'u1'
|
|
1012
|
+
LIMIT 1
|
|
1013
|
+
`).get();
|
|
1014
|
+
expect(billing).toEqual(expect.objectContaining({
|
|
1015
|
+
tier: 'pro',
|
|
1016
|
+
entitlement_state: 'active'
|
|
1017
|
+
}));
|
|
1018
|
+
});
|
|
1019
|
+
it('supersedes a stale checkout_pending selection when the user retries paid checkout', async () => {
|
|
1020
|
+
const now = new Date().toISOString();
|
|
1021
|
+
const previousExpiry = new Date(Date.now() - (60 * 60 * 1000)).toISOString();
|
|
1022
|
+
db.prepare(`
|
|
1023
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1024
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
1025
|
+
`).run(now, now);
|
|
1026
|
+
db.prepare(`
|
|
1027
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1028
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
1029
|
+
`).run(now, now);
|
|
1030
|
+
db.prepare(`
|
|
1031
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1032
|
+
VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
|
|
1033
|
+
`).run(now, now);
|
|
1034
|
+
db.prepare(`
|
|
1035
|
+
INSERT INTO account_entitlements (
|
|
1036
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
|
|
1037
|
+
) VALUES ('u1', 'default', 'pro-v1', 'checkout_pending', ?, ?, 'checkout-start', ?, ?)
|
|
1038
|
+
`).run(now, previousExpiry, now, now);
|
|
1039
|
+
db.prepare(`
|
|
1040
|
+
INSERT INTO user_billing (
|
|
1041
|
+
user_id, tenant_id, tier, entitlement_state, effective_until, created_at, updated_at
|
|
1042
|
+
) VALUES ('u1', 'default', 'pro', 'checkout_pending', ?, ?, ?)
|
|
1043
|
+
`).run(previousExpiry, now, now);
|
|
1044
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
1045
|
+
const req = createMockReq(JSON.stringify({
|
|
1046
|
+
planVersionId: 'team-v1',
|
|
1047
|
+
interval: 'month'
|
|
1048
|
+
}), {
|
|
1049
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1050
|
+
host: 'app.taskforcehq.ai'
|
|
1051
|
+
});
|
|
1052
|
+
const res = createMockRes();
|
|
1053
|
+
await checkoutHandler(req, res, {});
|
|
1054
|
+
expect(res.statusCode).toBe(200);
|
|
1055
|
+
const entitlement = db.prepare(`
|
|
1056
|
+
SELECT plan_version_id, entitlement_state, effective_until
|
|
1057
|
+
FROM account_entitlements
|
|
1058
|
+
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
1059
|
+
LIMIT 1
|
|
1060
|
+
`).get();
|
|
1061
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
1062
|
+
plan_version_id: 'team-v1',
|
|
1063
|
+
entitlement_state: 'checkout_pending'
|
|
1064
|
+
}));
|
|
1065
|
+
expect(entitlement?.effective_until).not.toBe(previousExpiry);
|
|
1066
|
+
const billing = db.prepare(`
|
|
1067
|
+
SELECT tier, entitlement_state, effective_until
|
|
1068
|
+
FROM user_billing
|
|
1069
|
+
WHERE tenant_id = 'default' AND user_id = 'u1'
|
|
1070
|
+
LIMIT 1
|
|
1071
|
+
`).get();
|
|
1072
|
+
expect(billing).toEqual(expect.objectContaining({
|
|
1073
|
+
tier: 'team',
|
|
1074
|
+
entitlement_state: 'checkout_pending'
|
|
1075
|
+
}));
|
|
1076
|
+
expect(billing?.effective_until).not.toBe(previousExpiry);
|
|
1077
|
+
});
|
|
1078
|
+
it('reuses an existing Stripe customer discovered by account email before creating checkout', async () => {
|
|
1079
|
+
const now = new Date().toISOString();
|
|
1080
|
+
db.prepare(`
|
|
1081
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1082
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
1083
|
+
`).run(now, now);
|
|
1084
|
+
db.prepare(`
|
|
1085
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1086
|
+
VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
|
|
1087
|
+
`).run(now, now);
|
|
1088
|
+
mockCustomersList.mockResolvedValueOnce({
|
|
1089
|
+
data: [{ id: 'cus_email_match' }]
|
|
1090
|
+
});
|
|
1091
|
+
mockSubscriptionList.mockResolvedValueOnce({ data: [] });
|
|
1092
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
1093
|
+
const req = createMockReq(JSON.stringify({
|
|
1094
|
+
planVersionId: 'collab-v1',
|
|
1095
|
+
interval: 'month'
|
|
1096
|
+
}), {
|
|
1097
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1098
|
+
host: 'app.taskforcehq.ai'
|
|
1099
|
+
});
|
|
1100
|
+
const res = createMockRes();
|
|
1101
|
+
await checkoutHandler(req, res, {});
|
|
1102
|
+
expect(res.statusCode).toBe(200);
|
|
1103
|
+
const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
|
|
1104
|
+
expect(args.customer).toBe('cus_email_match');
|
|
1105
|
+
expect(args.customer_email).toBeUndefined();
|
|
1106
|
+
});
|
|
590
1107
|
it('builds checkout return URLs from explicit local proxy origin headers', async () => {
|
|
591
1108
|
const now = new Date().toISOString();
|
|
592
1109
|
db.prepare(`
|
|
@@ -1063,6 +1580,282 @@ describe('Billing Webhook Routes', () => {
|
|
|
1063
1580
|
entitlement_state: 'active'
|
|
1064
1581
|
}));
|
|
1065
1582
|
});
|
|
1583
|
+
it('does not activate entitlement when the checkout session is still incomplete', async () => {
|
|
1584
|
+
const now = new Date().toISOString();
|
|
1585
|
+
db.prepare(`
|
|
1586
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1587
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
1588
|
+
`).run(now, now);
|
|
1589
|
+
db.prepare(`
|
|
1590
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1591
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
1592
|
+
`).run(now, now);
|
|
1593
|
+
db.prepare(`
|
|
1594
|
+
INSERT INTO account_entitlements (
|
|
1595
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
1596
|
+
) VALUES ('u1', 'default', 'pro-v1', 'checkout_pending', ?, 'checkout-start', ?, ?)
|
|
1597
|
+
`).run(now, now, now);
|
|
1598
|
+
mockCheckoutRetrieve.mockResolvedValueOnce({
|
|
1599
|
+
id: 'cs_open_123',
|
|
1600
|
+
mode: 'subscription',
|
|
1601
|
+
status: 'open',
|
|
1602
|
+
payment_status: 'unpaid',
|
|
1603
|
+
customer: 'cus_123',
|
|
1604
|
+
subscription: 'sub_123',
|
|
1605
|
+
metadata: { userId: 'u1' }
|
|
1606
|
+
});
|
|
1607
|
+
const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
|
|
1608
|
+
const req = createMockReq(JSON.stringify({
|
|
1609
|
+
sessionId: 'cs_open_123'
|
|
1610
|
+
}), {
|
|
1611
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1612
|
+
host: 'app.taskforcehq.ai'
|
|
1613
|
+
});
|
|
1614
|
+
const res = createMockRes();
|
|
1615
|
+
await confirmHandler(req, res, {});
|
|
1616
|
+
expect(res.statusCode).toBe(409);
|
|
1617
|
+
expect(JSON.parse(res.body)).toEqual({
|
|
1618
|
+
error: 'Checkout session has not completed yet.',
|
|
1619
|
+
code: 'CHECKOUT_SESSION_INCOMPLETE'
|
|
1620
|
+
});
|
|
1621
|
+
expect(mockSubscriptionRetrieve).not.toHaveBeenCalledWith('sub_123');
|
|
1622
|
+
const entitlement = db.prepare(`
|
|
1623
|
+
SELECT plan_version_id, entitlement_state, updated_by
|
|
1624
|
+
FROM account_entitlements
|
|
1625
|
+
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
1626
|
+
LIMIT 1
|
|
1627
|
+
`).get();
|
|
1628
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
1629
|
+
plan_version_id: 'pro-v1',
|
|
1630
|
+
entitlement_state: 'checkout_pending',
|
|
1631
|
+
updated_by: 'checkout-start'
|
|
1632
|
+
}));
|
|
1633
|
+
});
|
|
1634
|
+
it('rejects checkout confirmation when a newer pending checkout superseded the session plan', async () => {
|
|
1635
|
+
const now = new Date().toISOString();
|
|
1636
|
+
db.prepare(`
|
|
1637
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1638
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
1639
|
+
`).run(now, now);
|
|
1640
|
+
db.prepare(`
|
|
1641
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1642
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
1643
|
+
`).run(now, now);
|
|
1644
|
+
db.prepare(`
|
|
1645
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1646
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
1647
|
+
`).run(now, now);
|
|
1648
|
+
db.prepare(`
|
|
1649
|
+
INSERT INTO account_entitlements (
|
|
1650
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
|
|
1651
|
+
) VALUES ('u1', 'default', 'team-v1', 'checkout_pending', ?, ?, 'checkout-start', ?, ?)
|
|
1652
|
+
`).run(now, new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
|
|
1653
|
+
const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
|
|
1654
|
+
const req = createMockReq(JSON.stringify({
|
|
1655
|
+
sessionId: 'cs_stale_123'
|
|
1656
|
+
}), {
|
|
1657
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1658
|
+
host: 'app.taskforcehq.ai'
|
|
1659
|
+
});
|
|
1660
|
+
const res = createMockRes();
|
|
1661
|
+
await confirmHandler(req, res, {});
|
|
1662
|
+
expect(res.statusCode).toBe(409);
|
|
1663
|
+
expect(JSON.parse(res.body)).toEqual({
|
|
1664
|
+
error: 'This checkout session is no longer current for this account.',
|
|
1665
|
+
code: 'CHECKOUT_SESSION_SUPERSEDED'
|
|
1666
|
+
});
|
|
1667
|
+
const entitlement = db.prepare(`
|
|
1668
|
+
SELECT plan_version_id, entitlement_state
|
|
1669
|
+
FROM account_entitlements
|
|
1670
|
+
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
1671
|
+
LIMIT 1
|
|
1672
|
+
`).get();
|
|
1673
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
1674
|
+
plan_version_id: 'team-v1',
|
|
1675
|
+
entitlement_state: 'checkout_pending'
|
|
1676
|
+
}));
|
|
1677
|
+
});
|
|
1678
|
+
it('records the latest active-account checkout attempt and rejects confirmation for a superseded session', async () => {
|
|
1679
|
+
const now = new Date().toISOString();
|
|
1680
|
+
db.prepare(`
|
|
1681
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1682
|
+
VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
|
|
1683
|
+
`).run(now, now);
|
|
1684
|
+
db.prepare(`
|
|
1685
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1686
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
1687
|
+
`).run(now, now);
|
|
1688
|
+
db.prepare(`
|
|
1689
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1690
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
1691
|
+
`).run(now, now);
|
|
1692
|
+
db.prepare(`
|
|
1693
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1694
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
1695
|
+
`).run(now, now);
|
|
1696
|
+
db.prepare(`
|
|
1697
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1698
|
+
VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
|
|
1699
|
+
`).run(now, now);
|
|
1700
|
+
db.prepare(`
|
|
1701
|
+
INSERT INTO user_billing (
|
|
1702
|
+
user_id, tenant_id, tier, entitlement_state, created_at, updated_at
|
|
1703
|
+
) VALUES ('u1', 'default', 'starter', 'active', ?, ?)
|
|
1704
|
+
`).run(now, now);
|
|
1705
|
+
db.prepare(`
|
|
1706
|
+
INSERT INTO account_entitlements (
|
|
1707
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
1708
|
+
) VALUES ('u1', 'default', 'starter-v1', 'active', ?, 'free-plan-selection', ?, ?)
|
|
1709
|
+
`).run(now, now, now);
|
|
1710
|
+
mockCheckoutCreate
|
|
1711
|
+
.mockResolvedValueOnce({
|
|
1712
|
+
id: 'cs_old',
|
|
1713
|
+
url: 'https://example.test/checkout/old',
|
|
1714
|
+
expires_at: Math.floor(Date.now() / 1000) + (24 * 60 * 60)
|
|
1715
|
+
})
|
|
1716
|
+
.mockResolvedValueOnce({
|
|
1717
|
+
id: 'cs_new',
|
|
1718
|
+
url: 'https://example.test/checkout/new',
|
|
1719
|
+
expires_at: Math.floor(Date.now() / 1000) + (24 * 60 * 60)
|
|
1720
|
+
});
|
|
1721
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
1722
|
+
const firstReq = createMockReq(JSON.stringify({
|
|
1723
|
+
planVersionId: 'pro-v1',
|
|
1724
|
+
interval: 'month'
|
|
1725
|
+
}), {
|
|
1726
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1727
|
+
host: 'app.taskforcehq.ai'
|
|
1728
|
+
});
|
|
1729
|
+
const firstRes = createMockRes();
|
|
1730
|
+
await checkoutHandler(firstReq, firstRes, {});
|
|
1731
|
+
expect(firstRes.statusCode).toBe(200);
|
|
1732
|
+
const secondReq = createMockReq(JSON.stringify({
|
|
1733
|
+
planVersionId: 'team-v1',
|
|
1734
|
+
interval: 'month'
|
|
1735
|
+
}), {
|
|
1736
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1737
|
+
host: 'app.taskforcehq.ai'
|
|
1738
|
+
});
|
|
1739
|
+
const secondRes = createMockRes();
|
|
1740
|
+
await checkoutHandler(secondReq, secondRes, {});
|
|
1741
|
+
expect(secondRes.statusCode).toBe(200);
|
|
1742
|
+
const attempts = db.prepare(`
|
|
1743
|
+
SELECT session_id, plan_version_id, status
|
|
1744
|
+
FROM billing_checkout_attempts
|
|
1745
|
+
WHERE tenant_id = 'default' AND user_id = 'u1'
|
|
1746
|
+
ORDER BY session_id ASC
|
|
1747
|
+
`).all();
|
|
1748
|
+
expect(attempts).toEqual([
|
|
1749
|
+
{ session_id: 'cs_new', plan_version_id: 'team-v1', status: 'open' },
|
|
1750
|
+
{ session_id: 'cs_old', plan_version_id: 'pro-v1', status: 'superseded' }
|
|
1751
|
+
]);
|
|
1752
|
+
mockCheckoutRetrieve.mockResolvedValueOnce({
|
|
1753
|
+
id: 'cs_old',
|
|
1754
|
+
mode: 'subscription',
|
|
1755
|
+
status: 'complete',
|
|
1756
|
+
payment_status: 'paid',
|
|
1757
|
+
customer: 'cus_123',
|
|
1758
|
+
subscription: 'sub_old',
|
|
1759
|
+
metadata: { userId: 'u1', planVersionId: 'pro-v1' }
|
|
1760
|
+
});
|
|
1761
|
+
mockSubscriptionRetrieve.mockResolvedValueOnce({
|
|
1762
|
+
id: 'sub_old',
|
|
1763
|
+
customer: 'cus_123',
|
|
1764
|
+
status: 'active',
|
|
1765
|
+
trial_end: null,
|
|
1766
|
+
metadata: { userId: 'u1', planVersionId: 'pro-v1' },
|
|
1767
|
+
items: {
|
|
1768
|
+
data: [{
|
|
1769
|
+
id: 'si_old',
|
|
1770
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
1771
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
1772
|
+
}]
|
|
1773
|
+
}
|
|
1774
|
+
});
|
|
1775
|
+
const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
|
|
1776
|
+
const confirmReq = createMockReq(JSON.stringify({
|
|
1777
|
+
sessionId: 'cs_old'
|
|
1778
|
+
}), {
|
|
1779
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1780
|
+
host: 'app.taskforcehq.ai'
|
|
1781
|
+
});
|
|
1782
|
+
const confirmRes = createMockRes();
|
|
1783
|
+
await confirmHandler(confirmReq, confirmRes, {});
|
|
1784
|
+
expect(confirmRes.statusCode).toBe(409);
|
|
1785
|
+
expect(JSON.parse(confirmRes.body)).toEqual({
|
|
1786
|
+
error: 'This checkout session is no longer current for this account.',
|
|
1787
|
+
code: 'CHECKOUT_SESSION_SUPERSEDED'
|
|
1788
|
+
});
|
|
1789
|
+
});
|
|
1790
|
+
it('ignores subscription webhooks for a stale subscription after a newer checkout already won', async () => {
|
|
1791
|
+
const now = new Date().toISOString();
|
|
1792
|
+
db.prepare(`
|
|
1793
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1794
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
1795
|
+
`).run(now, now);
|
|
1796
|
+
db.prepare(`
|
|
1797
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1798
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
1799
|
+
`).run(now, now);
|
|
1800
|
+
db.prepare(`
|
|
1801
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1802
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
1803
|
+
`).run(now, now);
|
|
1804
|
+
db.prepare(`
|
|
1805
|
+
INSERT INTO user_billing (
|
|
1806
|
+
user_id, tenant_id, stripe_customer_id, stripe_subscription_id, tier, entitlement_state, created_at, updated_at
|
|
1807
|
+
) VALUES ('u1', 'default', 'cus_123', 'sub_current', 'team', 'active', ?, ?)
|
|
1808
|
+
`).run(now, now);
|
|
1809
|
+
db.prepare(`
|
|
1810
|
+
INSERT INTO account_entitlements (
|
|
1811
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
1812
|
+
) VALUES ('u1', 'default', 'team-v1', 'active', ?, 'checkout-confirm', ?, ?)
|
|
1813
|
+
`).run(now, now, now);
|
|
1814
|
+
mockConstructEvent.mockReturnValue({
|
|
1815
|
+
id: 'evt_stale_subscription_after_confirm',
|
|
1816
|
+
type: 'customer.subscription.updated',
|
|
1817
|
+
data: {
|
|
1818
|
+
object: {
|
|
1819
|
+
id: 'sub_old',
|
|
1820
|
+
customer: 'cus_123',
|
|
1821
|
+
status: 'active',
|
|
1822
|
+
trial_end: null,
|
|
1823
|
+
metadata: { planVersionId: 'pro-v1' },
|
|
1824
|
+
items: {
|
|
1825
|
+
data: [{
|
|
1826
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
1827
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
1828
|
+
}]
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
});
|
|
1833
|
+
const req = createMockReq('{"id":"evt_stale_subscription_after_confirm"}', {
|
|
1834
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1835
|
+
'stripe-signature': 'sig'
|
|
1836
|
+
});
|
|
1837
|
+
const res = createMockRes();
|
|
1838
|
+
await webhookHandler(req, res, {});
|
|
1839
|
+
expect(res.statusCode).toBe(200);
|
|
1840
|
+
const entitlement = db.prepare(`
|
|
1841
|
+
SELECT plan_version_id, entitlement_state
|
|
1842
|
+
FROM account_entitlements
|
|
1843
|
+
WHERE account_id = 'u1' AND tenant_id = 'default'
|
|
1844
|
+
LIMIT 1
|
|
1845
|
+
`).get();
|
|
1846
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
1847
|
+
plan_version_id: 'team-v1',
|
|
1848
|
+
entitlement_state: 'active'
|
|
1849
|
+
}));
|
|
1850
|
+
const failure = db.prepare(`
|
|
1851
|
+
SELECT reason
|
|
1852
|
+
FROM stripe_webhook_failures
|
|
1853
|
+
WHERE event_id = 'evt_stale_subscription_after_confirm'
|
|
1854
|
+
ORDER BY created_at DESC
|
|
1855
|
+
LIMIT 1
|
|
1856
|
+
`).get();
|
|
1857
|
+
expect(String(failure?.reason || '')).toContain('no longer current for this account');
|
|
1858
|
+
});
|
|
1066
1859
|
it('lists webhook failure diagnostics for system admin', async () => {
|
|
1067
1860
|
const now = new Date().toISOString();
|
|
1068
1861
|
const old = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
|
|
@@ -1092,6 +1885,49 @@ describe('Billing Webhook Routes', () => {
|
|
|
1092
1885
|
reason: 'diagnostic-1'
|
|
1093
1886
|
}));
|
|
1094
1887
|
});
|
|
1888
|
+
it('lists checkout attempt diagnostics for system admin and purges old terminal rows', async () => {
|
|
1889
|
+
const now = new Date().toISOString();
|
|
1890
|
+
const stale = new Date(Date.now() - 20 * 24 * 60 * 60 * 1000).toISOString();
|
|
1891
|
+
db.prepare(`
|
|
1892
|
+
INSERT INTO billing_checkout_attempts (
|
|
1893
|
+
session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
1894
|
+
) VALUES ('cs_keep_open', 'default', 'u1', 'team-v1', 'month', 'open', ?, ?, ?)
|
|
1895
|
+
`).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
|
|
1896
|
+
db.prepare(`
|
|
1897
|
+
INSERT INTO billing_checkout_attempts (
|
|
1898
|
+
session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
1899
|
+
) VALUES ('cs_purge_old', 'default', 'u1', 'pro-v1', 'month', 'superseded', ?, ?, ?)
|
|
1900
|
+
`).run(stale, stale, stale);
|
|
1901
|
+
db.prepare(`
|
|
1902
|
+
INSERT INTO billing_checkout_attempts (
|
|
1903
|
+
session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
1904
|
+
) VALUES ('cs_expire_now', 'default', 'u2', 'pro-v1', 'month', 'open', ?, ?, ?)
|
|
1905
|
+
`).run(new Date(Date.now() - 60 * 1000).toISOString(), now, now);
|
|
1906
|
+
const route = routeHandlers.get('GET /api/taskforce/admin/billing/checkout-attempts');
|
|
1907
|
+
expect(route).toBeTypeOf('function');
|
|
1908
|
+
const req = createMockReq('', {
|
|
1909
|
+
'x-taskforce-runtime-mode': 'cloud'
|
|
1910
|
+
});
|
|
1911
|
+
req.url = '/api/taskforce/admin/billing/checkout-attempts?limit=10';
|
|
1912
|
+
const res = createMockRes();
|
|
1913
|
+
await route(req, res, {});
|
|
1914
|
+
expect(res.statusCode).toBe(200);
|
|
1915
|
+
const payload = JSON.parse(res.body);
|
|
1916
|
+
expect(payload.purgedTerminalRows).toBeGreaterThanOrEqual(1);
|
|
1917
|
+
expect(payload.expiredRowsNormalized).toBeGreaterThanOrEqual(1);
|
|
1918
|
+
expect(payload.openCount).toBe(1);
|
|
1919
|
+
expect(payload.rows).toEqual(expect.arrayContaining([
|
|
1920
|
+
expect.objectContaining({ session_id: 'cs_keep_open', status: 'open' }),
|
|
1921
|
+
expect.objectContaining({ session_id: 'cs_expire_now', status: 'expired' })
|
|
1922
|
+
]));
|
|
1923
|
+
const purged = db.prepare(`
|
|
1924
|
+
SELECT session_id
|
|
1925
|
+
FROM billing_checkout_attempts
|
|
1926
|
+
WHERE session_id = 'cs_purge_old'
|
|
1927
|
+
LIMIT 1
|
|
1928
|
+
`).get();
|
|
1929
|
+
expect(purged).toBeUndefined();
|
|
1930
|
+
});
|
|
1095
1931
|
it('lists stripe price options filtered by interval for system admin', async () => {
|
|
1096
1932
|
mockPricesList.mockResolvedValueOnce({
|
|
1097
1933
|
data: [
|
|
@@ -1329,6 +2165,18 @@ describe('Billing Webhook Routes', () => {
|
|
|
1329
2165
|
await route(req, res, {});
|
|
1330
2166
|
expect(res.statusCode).toBe(403);
|
|
1331
2167
|
});
|
|
2168
|
+
it('rejects checkout attempt diagnostics for non-admin', async () => {
|
|
2169
|
+
const nonAdminHandlers = buildBillingRouteHandlers(db, { isAdmin: false });
|
|
2170
|
+
const route = nonAdminHandlers.get('GET /api/taskforce/admin/billing/checkout-attempts');
|
|
2171
|
+
expect(route).toBeTypeOf('function');
|
|
2172
|
+
const req = createMockReq('', {
|
|
2173
|
+
'x-taskforce-runtime-mode': 'cloud'
|
|
2174
|
+
});
|
|
2175
|
+
req.url = '/api/taskforce/admin/billing/checkout-attempts';
|
|
2176
|
+
const res = createMockRes();
|
|
2177
|
+
await route(req, res, {});
|
|
2178
|
+
expect(res.statusCode).toBe(403);
|
|
2179
|
+
});
|
|
1332
2180
|
it('returns public pricing catalog with cached Stripe amounts without auth', async () => {
|
|
1333
2181
|
const now = new Date().toISOString();
|
|
1334
2182
|
routeHandlers = buildBillingRouteHandlers(db);
|
|
@@ -1433,7 +2281,94 @@ describe('Billing Webhook Routes', () => {
|
|
|
1433
2281
|
expect(body.planVersionId).toBe('pro-v1');
|
|
1434
2282
|
expect(body.entitlementState).toBe('active');
|
|
1435
2283
|
});
|
|
1436
|
-
it('
|
|
2284
|
+
it('prefers canonical checkout_pending state over stale billing mirror state in billing status payload', async () => {
|
|
2285
|
+
routeHandlers = buildBillingRouteHandlers(db, {
|
|
2286
|
+
accessStatus: {
|
|
2287
|
+
gate: 'checkout_pending',
|
|
2288
|
+
hasEntitlement: true,
|
|
2289
|
+
canAccessApp: false,
|
|
2290
|
+
canAccessPlans: true,
|
|
2291
|
+
planSelectionRequired: false,
|
|
2292
|
+
message: 'Complete checkout to continue.',
|
|
2293
|
+
entitlement: {
|
|
2294
|
+
accountId: 'u1',
|
|
2295
|
+
planVersionId: 'pro-v1',
|
|
2296
|
+
planId: 'pro',
|
|
2297
|
+
state: 'checkout_pending',
|
|
2298
|
+
effectiveAt: null,
|
|
2299
|
+
effectiveUntil: null
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
});
|
|
2303
|
+
const now = new Date().toISOString();
|
|
2304
|
+
db.prepare(`
|
|
2305
|
+
INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
|
|
2306
|
+
VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
|
|
2307
|
+
`).run(now, now);
|
|
2308
|
+
db.prepare(`
|
|
2309
|
+
INSERT INTO user_billing (
|
|
2310
|
+
user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
|
|
2311
|
+
) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_pro_month', 'active', 'starter', 'month', 'active', ?, ?)
|
|
2312
|
+
`).run(now, now);
|
|
2313
|
+
const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
|
|
2314
|
+
expect(statusRoute).toBeTypeOf('function');
|
|
2315
|
+
const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
|
|
2316
|
+
const res = createMockRes();
|
|
2317
|
+
await statusRoute(req, res, {});
|
|
2318
|
+
expect(res.statusCode).toBe(200);
|
|
2319
|
+
const body = JSON.parse(res.body);
|
|
2320
|
+
expect(body).toEqual(expect.objectContaining({
|
|
2321
|
+
planId: 'pro',
|
|
2322
|
+
planName: 'Pro Plan',
|
|
2323
|
+
planVersionId: 'pro-v1',
|
|
2324
|
+
entitlementState: 'checkout_pending',
|
|
2325
|
+
gate: 'checkout_pending',
|
|
2326
|
+
message: 'Complete checkout to continue.',
|
|
2327
|
+
planSelectionRequired: false,
|
|
2328
|
+
billingConflictCode: null,
|
|
2329
|
+
stripeStatus: 'active',
|
|
2330
|
+
stripeCustomerId: 'cus_123',
|
|
2331
|
+
stripeSubscriptionId: 'sub_123'
|
|
2332
|
+
}));
|
|
2333
|
+
});
|
|
2334
|
+
it('returns checkout conflict code in billing status when the latest attempt expired', async () => {
|
|
2335
|
+
routeHandlers = buildBillingRouteHandlers(db, {
|
|
2336
|
+
accessStatus: {
|
|
2337
|
+
gate: 'plan_selection_required',
|
|
2338
|
+
hasEntitlement: true,
|
|
2339
|
+
canAccessApp: false,
|
|
2340
|
+
canAccessPlans: true,
|
|
2341
|
+
planSelectionRequired: true,
|
|
2342
|
+
message: 'Choose a plan to continue.',
|
|
2343
|
+
entitlement: {
|
|
2344
|
+
accountId: 'u1',
|
|
2345
|
+
planVersionId: 'pro-v1',
|
|
2346
|
+
planId: 'pro',
|
|
2347
|
+
state: 'pending_plan_selection',
|
|
2348
|
+
effectiveAt: null,
|
|
2349
|
+
effectiveUntil: null
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
});
|
|
2353
|
+
const now = new Date().toISOString();
|
|
2354
|
+
db.prepare(`
|
|
2355
|
+
INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
|
|
2356
|
+
VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
|
|
2357
|
+
`).run(now, now);
|
|
2358
|
+
db.prepare(`
|
|
2359
|
+
INSERT INTO billing_checkout_attempts (
|
|
2360
|
+
session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
2361
|
+
) VALUES ('cs_expired_status', 'default', 'u1', 'pro-v1', 'month', 'expired', ?, ?, ?)
|
|
2362
|
+
`).run(new Date(Date.now() - 60 * 1000).toISOString(), now, now);
|
|
2363
|
+
const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
|
|
2364
|
+
const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
|
|
2365
|
+
const res = createMockRes();
|
|
2366
|
+
await statusRoute(req, res, {});
|
|
2367
|
+
expect(res.statusCode).toBe(200);
|
|
2368
|
+
const body = JSON.parse(res.body);
|
|
2369
|
+
expect(body.billingConflictCode).toBe('CHECKOUT_SESSION_EXPIRED');
|
|
2370
|
+
});
|
|
2371
|
+
it('does not reconcile stale billing state from Stripe when billing status is requested', async () => {
|
|
1437
2372
|
const now = new Date().toISOString();
|
|
1438
2373
|
const stale = new Date(Date.now() - 60_000).toISOString();
|
|
1439
2374
|
db.prepare(`
|
|
@@ -1456,21 +2391,19 @@ describe('Billing Webhook Routes', () => {
|
|
|
1456
2391
|
db.prepare(`
|
|
1457
2392
|
INSERT INTO account_entitlements (
|
|
1458
2393
|
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
|
|
1459
|
-
) VALUES ('u1', 'default', '
|
|
2394
|
+
) VALUES ('u1', 'default', 'pro-v1', 'active', ?, ?, ?)
|
|
1460
2395
|
`).run(stale, stale, stale);
|
|
1461
2396
|
const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
|
|
1462
2397
|
const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
|
|
1463
2398
|
const res = createMockRes();
|
|
1464
2399
|
await statusRoute(req, res, {});
|
|
1465
2400
|
expect(res.statusCode).toBe(200);
|
|
1466
|
-
expect(mockSubscriptionRetrieve).
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
`).get();
|
|
1473
|
-
expect(entitlement?.plan_version_id).toBe('pro-v1');
|
|
2401
|
+
expect(mockSubscriptionRetrieve).not.toHaveBeenCalled();
|
|
2402
|
+
expect(mockCustomersList).not.toHaveBeenCalled();
|
|
2403
|
+
expect(mockSubscriptionList).not.toHaveBeenCalled();
|
|
2404
|
+
const body = JSON.parse(res.body);
|
|
2405
|
+
expect(body.planVersionId).toBe('pro-v1');
|
|
2406
|
+
expect(body.planName).toBe('Pro Plan');
|
|
1474
2407
|
});
|
|
1475
2408
|
it('skips Stripe reconciliation for fresh billing mirror rows when billing status is requested', async () => {
|
|
1476
2409
|
const now = new Date().toISOString();
|
|
@@ -1501,7 +2434,7 @@ describe('Billing Webhook Routes', () => {
|
|
|
1501
2434
|
expect(mockCustomersList).not.toHaveBeenCalled();
|
|
1502
2435
|
expect(mockSubscriptionList).not.toHaveBeenCalled();
|
|
1503
2436
|
});
|
|
1504
|
-
it('
|
|
2437
|
+
it('does not discover Stripe subscriptions by account email when billing status is requested', async () => {
|
|
1505
2438
|
const now = new Date().toISOString();
|
|
1506
2439
|
db.prepare(`
|
|
1507
2440
|
INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
|
|
@@ -1539,11 +2472,13 @@ describe('Billing Webhook Routes', () => {
|
|
|
1539
2472
|
const res = createMockRes();
|
|
1540
2473
|
await statusRoute(req, res, {});
|
|
1541
2474
|
expect(res.statusCode).toBe(200);
|
|
2475
|
+
expect(mockCustomersList).not.toHaveBeenCalled();
|
|
2476
|
+
expect(mockSubscriptionList).not.toHaveBeenCalled();
|
|
1542
2477
|
const body = JSON.parse(res.body);
|
|
1543
2478
|
expect(body.planId).toBe('pro');
|
|
1544
2479
|
expect(body.planVersionId).toBe('pro-v1');
|
|
1545
|
-
expect(body.stripeCustomerId).
|
|
1546
|
-
expect(body.stripeSubscriptionId).
|
|
2480
|
+
expect(body.stripeCustomerId).toBeNull();
|
|
2481
|
+
expect(body.stripeSubscriptionId).toBeNull();
|
|
1547
2482
|
});
|
|
1548
2483
|
it('prefers subscription metadata over shared price fallback during webhook processing', async () => {
|
|
1549
2484
|
const now = new Date().toISOString();
|