@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.
Files changed (192) hide show
  1. package/dist/Taskforce.module.css +113 -5
  2. package/dist/TaskforceCore.js +560 -208
  3. package/dist/TaskforceCore.test.js +1833 -91
  4. package/dist/annotatedAttachments/service.d.ts +1 -1
  5. package/dist/annotatedAttachments/types.d.ts +1 -1
  6. package/dist/components/features/AgentsModule.js +1 -1
  7. package/dist/components/features/AgentsModule.test.js +1 -1
  8. package/dist/components/features/TaskSettings.js +58 -38
  9. package/dist/components/features/TaskSettings.test.js +139 -40
  10. package/dist/components/task/TaskActionHeader.d.ts +2 -1
  11. package/dist/components/task/TaskActionHeader.js +3 -3
  12. package/dist/components/task/TaskActionHeader.test.js +8 -0
  13. package/dist/components/task/TaskCard.js +16 -1
  14. package/dist/components/task/TaskCard.test.js +31 -0
  15. package/dist/components/task/TaskForm.d.ts +2 -1
  16. package/dist/components/task/TaskForm.js +22 -4
  17. package/dist/components/task/TaskForm.test.js +46 -0
  18. package/dist/components/task/TaskKanban.d.ts +1 -1
  19. package/dist/components/task/TaskKanban.js +31 -160
  20. package/dist/components/task/TaskKanban.test.js +17 -0
  21. package/dist/components/task/TaskStatusActions.d.ts +6 -1
  22. package/dist/components/task/TaskStatusActions.js +8 -3
  23. package/dist/components/task/TaskStatusActions.test.js +9 -0
  24. package/dist/components/task/taskKanbanDropRules.d.ts +58 -0
  25. package/dist/components/task/taskKanbanDropRules.js +187 -0
  26. package/dist/components/task/taskKanbanDropRules.test.d.ts +1 -0
  27. package/dist/components/task/taskKanbanDropRules.test.js +114 -0
  28. package/dist/components/ui/Modal.d.ts +1 -1
  29. package/dist/components/ui/Modal.js +1 -0
  30. package/dist/components/views/AppShellHeader.js +5 -1
  31. package/dist/components/views/AppShellHeader.test.js +4 -0
  32. package/dist/components/views/PlanComparisonPage.d.ts +3 -2
  33. package/dist/components/views/PlanComparisonPage.js +4 -2
  34. package/dist/components/views/PlanComparisonPage.test.js +4 -0
  35. package/dist/components/views/PlansPage.d.ts +7 -1
  36. package/dist/components/views/PlansPage.js +139 -53
  37. package/dist/components/views/PlansPage.test.d.ts +1 -0
  38. package/dist/components/views/PlansPage.test.js +157 -0
  39. package/dist/components/views/StandaloneLayout.d.ts +1 -0
  40. package/dist/components/views/StandaloneLayout.js +216 -186
  41. package/dist/components/views/WidgetView.js +11 -2
  42. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +8 -3
  43. package/dist/components/views/standalone/modals/AccountHubModal.js +16 -2
  44. package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +1 -0
  45. package/dist/components/views/standalone/modals/AccountHubModal.test.js +26 -0
  46. package/dist/components/views/standalone/modals/SyncStatusModal.js +1 -1
  47. package/dist/core/DeletedTaskLifecycleService.d.ts +2 -1
  48. package/dist/core/EntitlementsPolicy.test.js +2 -2
  49. package/dist/core/GlobalSettingsService.js +78 -18
  50. package/dist/core/PlanEntitlementService.d.ts +31 -5
  51. package/dist/core/PlanEntitlementService.js +350 -222
  52. package/dist/core/SignupMonetizationSettings.test.js +208 -49
  53. package/dist/core/SystemAdmin.test.js +186 -96
  54. package/dist/core/TaskActivityService.d.ts +4 -1
  55. package/dist/core/TaskActivityService.js +45 -0
  56. package/dist/core/TaskAuditTimeline.test.js +52 -1
  57. package/dist/core/Taskforce.d.ts +65 -20
  58. package/dist/core/Taskforce.js +261 -51
  59. package/dist/core/Taskforce.mcpAuth.test.js +0 -2
  60. package/dist/core/UserProfileAvatarDrafts.test.js +12 -3
  61. package/dist/core/WorkspacePlanMode.test.js +65 -0
  62. package/dist/core/types.d.ts +11 -12
  63. package/dist/documentReviews/service.d.ts +1 -1
  64. package/dist/documentReviews/types.d.ts +1 -1
  65. package/dist/hooks/auth/useTaskforceAuthBootstrap.d.ts +48 -0
  66. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +219 -0
  67. package/dist/hooks/sync/auth.d.ts +37 -0
  68. package/dist/hooks/sync/auth.js +61 -0
  69. package/dist/hooks/sync/bootstrap.d.ts +34 -0
  70. package/dist/hooks/sync/bootstrap.js +52 -0
  71. package/dist/hooks/sync/orchestratorShared.d.ts +36 -0
  72. package/dist/hooks/sync/orchestratorShared.js +209 -0
  73. package/dist/hooks/sync/orchestratorShared.test.d.ts +1 -0
  74. package/dist/hooks/sync/orchestratorShared.test.js +49 -0
  75. package/dist/hooks/sync/realtime.d.ts +25 -0
  76. package/dist/hooks/sync/realtime.js +60 -0
  77. package/dist/hooks/sync/recovery.d.ts +69 -0
  78. package/dist/hooks/sync/recovery.js +118 -0
  79. package/dist/hooks/sync/transfers.d.ts +122 -0
  80. package/dist/hooks/sync/transfers.js +431 -0
  81. package/dist/hooks/sync/useDataVersionRefresh.d.ts +16 -0
  82. package/dist/hooks/sync/useDataVersionRefresh.js +73 -0
  83. package/dist/hooks/ui/useResourceExport.d.ts +3 -1
  84. package/dist/hooks/ui/useResourceExport.js +5 -2
  85. package/dist/hooks/ui/useWorkflowTemplates.js +11 -6
  86. package/dist/hooks/useRealtimeSync.js +2 -1
  87. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +8 -0
  88. package/dist/hooks/useSyncOrchestrator.context-assets.test.d.ts +1 -0
  89. package/dist/hooks/useSyncOrchestrator.context-assets.test.js +228 -0
  90. package/dist/hooks/useSyncOrchestrator.d.ts +5 -4
  91. package/dist/hooks/useSyncOrchestrator.js +315 -860
  92. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +665 -8
  93. package/dist/hooks/useTaskMutations.js +45 -6
  94. package/dist/hooks/useTaskMutations.test.js +147 -0
  95. package/dist/hooks/useTaskforce.d.ts +11 -2
  96. package/dist/hooks/useTaskforce.js +290 -812
  97. package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
  98. package/dist/hooks/useWorkspaceSyncController.js +22 -10
  99. package/dist/hooks/workspace/useTaskforceApiRouting.test.js +17 -0
  100. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +166 -0
  101. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +434 -0
  102. package/dist/hooks/workspace/workspaceLocalState.d.ts +19 -0
  103. package/dist/hooks/workspace/workspaceLocalState.js +100 -0
  104. package/dist/hooks/workspace/workspaceLocalState.test.d.ts +1 -0
  105. package/dist/hooks/workspace/workspaceLocalState.test.js +59 -0
  106. package/dist/localization/locales/en-US.js +2 -2
  107. package/dist/mcp/canonicalAssetHelpers.d.ts +70 -0
  108. package/dist/mcp/canonicalAssetHelpers.js +259 -0
  109. package/dist/mcp/clientIntegrations.d.ts +12 -1
  110. package/dist/mcp/clientIntegrations.js +161 -6
  111. package/dist/mcp/clientIntegrations.test.js +76 -4
  112. package/dist/mcp/documentHelpers.d.ts +100 -0
  113. package/dist/mcp/documentHelpers.js +161 -0
  114. package/dist/mcp/runtime.js +264 -505
  115. package/dist/mcp/runtime.test.js +353 -89
  116. package/dist/mcp/taskAttachmentHelpers.d.ts +41 -0
  117. package/dist/mcp/taskAttachmentHelpers.js +60 -0
  118. package/dist/mcp/taskAttachmentTypes.d.ts +37 -0
  119. package/dist/mcp/taskAttachmentTypes.js +1 -0
  120. package/dist/migrations/billingSchemaParity.test.js +3 -0
  121. package/dist/migrations/taskSchemaMigrations.js +15 -0
  122. package/dist/plugins/vite.js +1 -1
  123. package/dist/runtime/appGateDefinitions.d.ts +1 -1
  124. package/dist/runtime/appGateDefinitions.js +1 -1
  125. package/dist/server/index.cookieProxy.test.js +1 -0
  126. package/dist/server/index.d.ts +1 -0
  127. package/dist/server/index.js +48 -3
  128. package/dist/server/index.test.js +14 -2
  129. package/dist/server/mockStripe.d.ts +1 -0
  130. package/dist/server/mockStripe.js +2 -0
  131. package/dist/server/routes/admin.js +91 -40
  132. package/dist/server/routes/auth.d.ts +1 -0
  133. package/dist/server/routes/auth.js +231 -55
  134. package/dist/server/routes/billing.js +473 -39
  135. package/dist/server/routes/billing.test.js +972 -37
  136. package/dist/server/routes/shared.d.ts +30 -0
  137. package/dist/server/routes/shared.js +31 -3
  138. package/dist/server/routes/sync.integration.test.js +180 -0
  139. package/dist/server/routes/sync.js +125 -242
  140. package/dist/server/routes/tasks.js +49 -7
  141. package/dist/server/routes.js +11 -22
  142. package/dist/server/routes.test.js +530 -81
  143. package/dist/shared/taskActor.d.ts +10 -0
  144. package/dist/shared/taskActor.js +1 -0
  145. package/dist/sync/cloudSyncApi.d.ts +1 -0
  146. package/dist/sync/collaborationSyncPayload.js +4 -0
  147. package/dist/sync/collaborationSyncPayload.test.js +8 -0
  148. package/dist/sync/collaborationSyncState.d.ts +3 -1
  149. package/dist/sync/collaborationSyncState.js +19 -0
  150. package/dist/sync/contentSyncState.d.ts +10 -0
  151. package/dist/sync/contentSyncState.js +277 -14
  152. package/dist/sync/syncApplyHandlers.d.ts +87 -1
  153. package/dist/sync/syncApplyHandlers.js +370 -8
  154. package/dist/sync/syncApplyHandlers.test.d.ts +1 -0
  155. package/dist/sync/syncApplyHandlers.test.js +377 -0
  156. package/dist/sync/syncService.d.ts +9 -1
  157. package/dist/sync/syncService.js +57 -10
  158. package/dist/sync/syncService.test.js +48 -0
  159. package/dist/sync/workspacePullFeed.d.ts +11 -0
  160. package/dist/sync/workspacePullFeed.js +123 -68
  161. package/dist/sync/workspacePullFeed.test.js +180 -0
  162. package/dist/sync/workspaceSyncModel.d.ts +22 -1
  163. package/dist/sync/workspaceSyncModel.js +65 -0
  164. package/dist/sync/workspaceSyncModel.test.js +119 -0
  165. package/dist/types.d.ts +2 -10
  166. package/dist/ui/assets/AgentsModule-BLWVbuKP.js +1 -0
  167. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-Dvi02Y7q.js → AnnotatedAttachmentWorkspace-BlS-cqnl.js} +1 -1
  168. package/dist/ui/assets/{DocumentWorkspace-iys3zfca.js → DocumentWorkspace-BH_6DF6x.js} +1 -1
  169. package/dist/ui/assets/{InitiativesModule-DJvLJI3A.js → InitiativesModule-BjR6iBf9.js} +1 -1
  170. package/dist/ui/assets/{PlansPage-BlVC_lRq.css → PlansPage-C2dd2n1X.css} +1 -1
  171. package/dist/ui/assets/PlansPage-CSDQ4sLa.js +1 -0
  172. package/dist/ui/assets/TaskSettings-CEDy34Jo.js +9 -0
  173. package/dist/ui/assets/{WorkflowsModule-BguHeQaV.js → WorkflowsModule-DnFqdUME.js} +1 -1
  174. package/dist/ui/assets/index-DXUdtH1B.js +6 -0
  175. package/dist/ui/assets/index-Ii0B0rTO.css +1 -0
  176. package/dist/ui/assets/{vendor-icons-C8EED3Oh.js → vendor-icons-I7ZwG1z_.js} +1 -1
  177. package/dist/ui/index.html +4 -4
  178. package/dist/utils/accountProfileSummaryCache.d.ts +31 -0
  179. package/dist/utils/accountProfileSummaryCache.js +97 -0
  180. package/dist/utils/accountProfileSummaryCache.test.d.ts +1 -0
  181. package/dist/utils/accountProfileSummaryCache.test.js +63 -0
  182. package/dist/utils/taskActivity.js +4 -0
  183. package/dist/utils/taskActivity.test.js +25 -1
  184. package/package.json +5 -3
  185. package/scripts/playwright-auth.sh +4 -0
  186. package/dist/ui/assets/AgentsModule-JG5jc3he.js +0 -1
  187. package/dist/ui/assets/PlansPage-BMPlpFpw.js +0 -1
  188. package/dist/ui/assets/TaskSettings-Bqd4cCzW.js +0 -8
  189. package/dist/ui/assets/index-MXeWoebC.css +0 -1
  190. package/dist/ui/assets/index-YJ4k5QNa.js +0 -6
  191. package/dist/utils/billingStatusCache.d.ts +0 -21
  192. 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 () => ({ id: 'cs_test', url: 'https://example.test/checkout' }));
9
- const mockCheckoutRetrieve = vi.fn(async (id) => ({ id, mode: 'subscription', customer: 'cus_123', subscription: 'sub_123', metadata: { userId: 'u1' } }));
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
- accountId: 'u1',
233
- planVersionId: 'pro-v1',
234
- planId: 'pro',
235
- state: 'active',
236
- effectiveAt: null,
237
- effectiveUntil: null,
238
- updatedBy: null,
239
- createdAt: '',
240
- updatedAt: ''
241
- }),
242
- getAccountAccessStatus: () => ({
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('reuses an existing Stripe customer discovered by account email before creating checkout', async () => {
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
- mockCustomersList.mockResolvedValueOnce({
572
- data: [{ id: 'cus_email_match' }]
573
- });
574
- mockSubscriptionList.mockResolvedValueOnce({ data: [] });
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
- const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
587
- expect(args.customer).toBe('cus_email_match');
588
- expect(args.customer_email).toBeUndefined();
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('reconciles stale entitlement from Stripe when billing status is requested', async () => {
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', 'starter-v1', 'active', ?, ?, ?)
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).toHaveBeenCalledWith('sub_123');
1467
- const entitlement = db.prepare(`
1468
- SELECT plan_version_id
1469
- FROM account_entitlements
1470
- WHERE tenant_id = 'default' AND account_id = 'u1'
1471
- LIMIT 1
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('repairs billing status from an active Stripe subscription discovered by account email', async () => {
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).toBe('cus_email_match');
1546
- expect(body.stripeSubscriptionId).toBe('sub_email_match');
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();