@taskforcehq/taskforce 0.3.301 → 0.3.303

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/dist/Taskforce.module.css +37 -2
  2. package/dist/TaskforceCore.filter-persistence.test.js +1 -1
  3. package/dist/TaskforceCore.js +72 -33
  4. package/dist/TaskforceCore.test.js +848 -130
  5. package/dist/components/features/TaskSettings.js +14 -82
  6. package/dist/components/features/TaskSettings.test.js +33 -70
  7. package/dist/components/features/settings/TaxonomyLibraryManager.js +8 -2
  8. package/dist/components/features/settings/TaxonomyLibraryManager.test.js +1 -1
  9. package/dist/components/task/TaskActionHeader.js +6 -3
  10. package/dist/components/task/TaskCard.js +6 -3
  11. package/dist/components/task/TaskForm.js +2 -2
  12. package/dist/components/task/TaskForm.test.js +41 -2
  13. package/dist/components/task/TaskKanban.test.js +17 -0
  14. package/dist/components/ui/ReferenceBadgeButton.js +1 -1
  15. package/dist/components/views/PlansPage.d.ts +3 -1
  16. package/dist/components/views/PlansPage.js +109 -49
  17. package/dist/components/views/PlansPage.test.d.ts +1 -0
  18. package/dist/components/views/PlansPage.test.js +100 -0
  19. package/dist/components/views/StandaloneLayout.d.ts +1 -0
  20. package/dist/components/views/StandaloneLayout.js +473 -256
  21. package/dist/components/views/panels/PlanningDrawer.js +18 -1
  22. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +48 -0
  23. package/dist/components/views/standalone/modals/AccountHubModal.js +36 -0
  24. package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +1 -0
  25. package/dist/components/views/standalone/modals/AccountHubModal.test.js +26 -0
  26. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.d.ts +9 -0
  27. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.js +8 -0
  28. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +26 -0
  29. package/dist/components/views/standalone/modals/EditProfileModal.js +10 -0
  30. package/dist/components/views/standalone/modals/HelpModal.d.ts +9 -0
  31. package/dist/components/views/standalone/modals/HelpModal.js +8 -0
  32. package/dist/components/views/standalone/modals/ScheduleWarningModal.d.ts +13 -0
  33. package/dist/components/views/standalone/modals/ScheduleWarningModal.js +6 -0
  34. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +61 -0
  35. package/dist/components/views/standalone/modals/SyncStatusModal.js +38 -0
  36. package/dist/components/views/standalone/modals/TeamManagementModal.d.ts +64 -0
  37. package/dist/components/views/standalone/modals/TeamManagementModal.js +10 -0
  38. package/dist/components/views/standalone/modals/types.d.ts +2 -0
  39. package/dist/components/views/standalone/modals/types.js +1 -0
  40. package/dist/components/views/teamManagementAccess.d.ts +1 -1
  41. package/dist/components/views/teamManagementAccess.js +2 -2
  42. package/dist/components/views/teamManagementAccess.test.js +3 -3
  43. package/dist/config/deployment.js +2 -2
  44. package/dist/config/deployment.test.js +5 -3
  45. package/dist/core/AiProfileService.js +7 -2
  46. package/dist/core/AiProfiles.test.js +19 -0
  47. package/dist/core/EntitlementsPolicy.test.js +2 -2
  48. package/dist/core/GlobalSettingsService.js +78 -18
  49. package/dist/core/PlanEntitlementService.d.ts +29 -5
  50. package/dist/core/PlanEntitlementService.js +297 -219
  51. package/dist/core/SignupMonetizationSettings.test.js +173 -49
  52. package/dist/core/SyncReconciliationService.d.ts +2 -0
  53. package/dist/core/SyncReconciliationService.js +3 -0
  54. package/dist/core/SystemAdmin.test.js +186 -96
  55. package/dist/core/TaskAuditTimeline.test.js +40 -0
  56. package/dist/core/Taskforce.d.ts +100 -10
  57. package/dist/core/Taskforce.ids.test.js +133 -0
  58. package/dist/core/Taskforce.js +459 -127
  59. package/dist/core/Taskforce.mcpAuth.test.js +0 -2
  60. package/dist/core/UserProfileAvatarDrafts.test.d.ts +1 -0
  61. package/dist/core/UserProfileAvatarDrafts.test.js +111 -0
  62. package/dist/core/taskReferencePolicy.d.ts +28 -0
  63. package/dist/core/taskReferencePolicy.js +189 -0
  64. package/dist/core/types.d.ts +13 -12
  65. package/dist/hooks/tasks/useTaskFormActions.js +3 -0
  66. package/dist/hooks/useSyncOrchestrator.js +17 -3
  67. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +97 -5
  68. package/dist/hooks/useTaskData.js +3 -2
  69. package/dist/hooks/useTaskData.test.js +66 -0
  70. package/dist/hooks/useTaskforce.d.ts +8 -0
  71. package/dist/hooks/useTaskforce.js +72 -19
  72. package/dist/hooks/useTaskforce.sync-behavior.test.js +185 -6
  73. package/dist/mcp/aiProfileBootstrap.d.ts +29 -0
  74. package/dist/mcp/aiProfileBootstrap.js +80 -0
  75. package/dist/mcp/aiProfileBootstrap.test.d.ts +1 -0
  76. package/dist/mcp/aiProfileBootstrap.test.js +68 -0
  77. package/dist/mcp/clientIntegrations.d.ts +2 -8
  78. package/dist/mcp/clientIntegrations.js +21 -40
  79. package/dist/mcp/clientIntegrations.test.js +28 -2
  80. package/dist/mcp/clientProfileDefaults.d.ts +12 -0
  81. package/dist/mcp/clientProfileDefaults.js +37 -0
  82. package/dist/mcp/runtime.d.ts +10 -7
  83. package/dist/mcp/runtime.js +131 -48
  84. package/dist/mcp/runtime.test.js +321 -83
  85. package/dist/migrations/billingSchemaParity.test.js +3 -0
  86. package/dist/migrations/taskSchemaMigrations.js +68 -1
  87. package/dist/qaOpen.test.d.ts +1 -0
  88. package/dist/qaOpen.test.js +56 -0
  89. package/dist/server/index.cookieProxy.test.js +2 -0
  90. package/dist/server/index.d.ts +1 -0
  91. package/dist/server/index.js +17 -1
  92. package/dist/server/index.test.js +15 -1
  93. package/dist/server/mockStripe.d.ts +1 -0
  94. package/dist/server/mockStripe.js +2 -0
  95. package/dist/server/routes/admin.js +105 -45
  96. package/dist/server/routes/auth.d.ts +6 -0
  97. package/dist/server/routes/auth.js +505 -30
  98. package/dist/server/routes/billing.js +517 -24
  99. package/dist/server/routes/billing.test.js +967 -45
  100. package/dist/server/routes/documents.js +6 -1
  101. package/dist/server/routes/shared.d.ts +1 -1
  102. package/dist/server/routes/shared.js +16 -6
  103. package/dist/server/routes/sync.integration.test.js +290 -0
  104. package/dist/server/routes/sync.js +234 -1922
  105. package/dist/server/routes/tasks.js +18 -4
  106. package/dist/server/routes.js +29 -22
  107. package/dist/server/routes.test.js +639 -82
  108. package/dist/shared/runtimeContract.d.ts +1 -1
  109. package/dist/sync/cloudSyncApi.d.ts +1 -0
  110. package/dist/sync/collaborationSyncPayload.d.ts +17 -0
  111. package/dist/sync/collaborationSyncPayload.js +131 -0
  112. package/dist/sync/collaborationSyncPayload.test.d.ts +1 -0
  113. package/dist/sync/collaborationSyncPayload.test.js +104 -0
  114. package/dist/sync/collaborationSyncState.d.ts +15 -0
  115. package/dist/sync/collaborationSyncState.js +60 -0
  116. package/dist/sync/contentSyncPayload.d.ts +45 -0
  117. package/dist/sync/contentSyncPayload.js +104 -0
  118. package/dist/sync/contentSyncPayload.test.d.ts +1 -0
  119. package/dist/sync/contentSyncPayload.test.js +104 -0
  120. package/dist/sync/contentSyncState.d.ts +98 -0
  121. package/dist/sync/contentSyncState.js +1004 -0
  122. package/dist/sync/planningSyncPayload.d.ts +9 -0
  123. package/dist/sync/planningSyncPayload.js +82 -0
  124. package/dist/sync/planningSyncPayload.test.d.ts +1 -0
  125. package/dist/sync/planningSyncPayload.test.js +68 -0
  126. package/dist/sync/syncApplyHandlers.d.ts +83 -0
  127. package/dist/sync/syncApplyHandlers.js +445 -0
  128. package/dist/sync/syncService.d.ts +1 -0
  129. package/dist/sync/syncService.js +21 -8
  130. package/dist/sync/taskSyncPayload.d.ts +33 -0
  131. package/dist/sync/taskSyncPayload.js +126 -0
  132. package/dist/sync/taskSyncPayload.test.d.ts +1 -0
  133. package/dist/sync/taskSyncPayload.test.js +54 -0
  134. package/dist/sync/workspacePullFeed.d.ts +38 -0
  135. package/dist/sync/workspacePullFeed.js +349 -0
  136. package/dist/sync/workspacePullFeed.test.d.ts +1 -0
  137. package/dist/sync/workspacePullFeed.test.js +278 -0
  138. package/dist/sync/workspaceSyncModel.d.ts +4 -1
  139. package/dist/sync/workspaceSyncModel.js +9 -2
  140. package/dist/sync/workspaceSyncModel.test.js +29 -0
  141. package/dist/test/setup.js +1 -1
  142. package/dist/types.d.ts +1 -0
  143. package/dist/ui/assets/{AgentsModule-Bdq3T1Ts.js → AgentsModule-CpsTmrIW.js} +1 -1
  144. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-jKUVo-PJ.js → AnnotatedAttachmentWorkspace-C_TmXZwA.js} +1 -1
  145. package/dist/ui/assets/{DocumentWorkspace-DuQa-uIC.js → DocumentWorkspace-C3GyzrWm.js} +2 -2
  146. package/dist/ui/assets/{InitiativesModule-C8Pi2VTk.js → InitiativesModule-4-Rh2K2n.js} +1 -1
  147. package/dist/ui/assets/PlansPage-BP7AYOqr.js +1 -0
  148. package/dist/ui/assets/TaskSettings-BOC5F6Ag.js +8 -0
  149. package/dist/ui/assets/{TaskSettings-BwB9NtmJ.css → TaskSettings-CnIBL_Eb.css} +1 -1
  150. package/dist/ui/assets/{WorkflowsModule-b6TLuHwJ.js → WorkflowsModule-CGk9s3NJ.js} +1 -1
  151. package/dist/ui/assets/index-CLIMgR6g.js +6 -0
  152. package/dist/ui/assets/index-DneETxcu.css +1 -0
  153. package/dist/ui/assets/{vendor-icons-QZyhEwgT.js → vendor-icons-pWocEipT.js} +1 -1
  154. package/dist/ui/index.html +3 -3
  155. package/dist/utils/accountProfileSummaryCache.d.ts +31 -0
  156. package/dist/utils/accountProfileSummaryCache.js +97 -0
  157. package/dist/utils/accountProfileSummaryCache.test.d.ts +1 -0
  158. package/dist/utils/accountProfileSummaryCache.test.js +63 -0
  159. package/dist/utils/taskActivity.js +17 -4
  160. package/dist/utils/taskActivity.test.js +27 -0
  161. package/dist/utils/taskNormalization.js +10 -2
  162. package/dist/utils/taskReferences.d.ts +11 -1
  163. package/dist/utils/taskReferences.js +33 -1
  164. package/package.json +4 -3
  165. package/scripts/qa-open-lib.mjs +175 -0
  166. package/scripts/qa-open-lib.mjs.d.ts +49 -0
  167. package/scripts/qa-open.mjs +352 -0
  168. package/dist/ui/assets/PlansPage-B6BDECSi.js +0 -1
  169. package/dist/ui/assets/TaskSettings-DUJgRTPU.js +0 -8
  170. package/dist/ui/assets/index-BiiF1cLQ.css +0 -1
  171. package/dist/ui/assets/index-xBWLPOEb.js +0 -6
@@ -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,
@@ -228,23 +251,25 @@ function buildBillingRouteHandlers(db, opts) {
228
251
  };
229
252
  });
230
253
  },
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: () => ({
254
+ getAccountEntitlement: () => {
255
+ const entitlement = opts?.accessStatus?.entitlement;
256
+ return {
257
+ accountId: String(entitlement?.accountId || 'u1'),
258
+ planVersionId: String(entitlement?.planVersionId || 'pro-v1'),
259
+ planId: String(entitlement?.planId || 'pro'),
260
+ state: String(entitlement?.state || 'active'),
261
+ effectiveAt: entitlement?.effectiveAt ?? null,
262
+ effectiveUntil: entitlement?.effectiveUntil ?? null,
263
+ updatedBy: null,
264
+ createdAt: '',
265
+ updatedAt: ''
266
+ };
267
+ },
268
+ getAccountAccessStatus: () => opts?.accessStatus || ({
243
269
  gate: 'ok',
244
270
  hasEntitlement: true,
245
271
  canAccessApp: true,
246
272
  canAccessPlans: true,
247
- signupMonetizationStrategy: 'auto_assign_default',
248
273
  planSelectionRequired: false,
249
274
  message: null,
250
275
  entitlement: {
@@ -421,6 +446,286 @@ describe('Billing Webhook Routes', () => {
421
446
  `).get();
422
447
  expect(failure.reason).toContain('Unknown price mapping');
423
448
  });
449
+ it('keeps blocked accounts in checkout_pending when Stripe reports an incomplete subscription', async () => {
450
+ const now = new Date().toISOString();
451
+ db.prepare(`
452
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
453
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
454
+ `).run(now, now);
455
+ db.prepare(`
456
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
457
+ VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
458
+ `).run(now, now);
459
+ db.prepare(`
460
+ INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
461
+ VALUES ('u1', 'default', 'cus_123', 'checkout_pending', ?, ?)
462
+ `).run(now, now);
463
+ db.prepare(`
464
+ INSERT INTO account_entitlements (
465
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
466
+ ) VALUES ('u1', 'default', 'pro-v1', 'checkout_pending', ?, 'checkout-start', ?, ?)
467
+ `).run(now, now, now);
468
+ mockConstructEvent.mockReturnValue({
469
+ id: 'evt_incomplete_subscription',
470
+ type: 'customer.subscription.updated',
471
+ data: {
472
+ object: {
473
+ id: 'sub_123',
474
+ customer: 'cus_123',
475
+ status: 'incomplete',
476
+ trial_end: null,
477
+ metadata: { planVersionId: 'pro-v1' },
478
+ items: {
479
+ data: [{
480
+ current_period_end: Math.floor(Date.now() / 1000) + 86400,
481
+ price: { id: 'price_pro_month', recurring: { interval: 'month' } }
482
+ }]
483
+ }
484
+ }
485
+ }
486
+ });
487
+ const req = createMockReq('{"id":"evt_incomplete_subscription"}', {
488
+ 'x-taskforce-runtime-mode': 'cloud',
489
+ 'stripe-signature': 'sig'
490
+ });
491
+ const res = createMockRes();
492
+ await webhookHandler(req, res, {});
493
+ expect(res.statusCode).toBe(200);
494
+ const entitlement = db.prepare(`
495
+ SELECT plan_version_id, entitlement_state, updated_by
496
+ FROM account_entitlements
497
+ WHERE account_id = 'u1' AND tenant_id = 'default'
498
+ LIMIT 1
499
+ `).get();
500
+ expect(entitlement).toEqual(expect.objectContaining({
501
+ plan_version_id: 'pro-v1',
502
+ entitlement_state: 'checkout_pending',
503
+ updated_by: 'stripe-webhook'
504
+ }));
505
+ const billing = db.prepare(`
506
+ SELECT entitlement_state, stripe_status, stripe_subscription_id
507
+ FROM user_billing
508
+ WHERE user_id = 'u1' AND tenant_id = 'default'
509
+ LIMIT 1
510
+ `).get();
511
+ expect(billing).toEqual(expect.objectContaining({
512
+ entitlement_state: 'checkout_pending',
513
+ stripe_status: 'incomplete',
514
+ stripe_subscription_id: 'sub_123'
515
+ }));
516
+ });
517
+ it('preserves active access when Stripe reports an incomplete subscription for an already-active account', async () => {
518
+ const now = new Date().toISOString();
519
+ db.prepare(`
520
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
521
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
522
+ `).run(now, now);
523
+ db.prepare(`
524
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
525
+ VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
526
+ `).run(now, now);
527
+ db.prepare(`
528
+ INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
529
+ VALUES ('u1', 'default', 'cus_123', 'active', ?, ?)
530
+ `).run(now, now);
531
+ db.prepare(`
532
+ INSERT INTO account_entitlements (
533
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
534
+ ) VALUES ('u1', 'default', 'pro-v1', 'active', ?, 'stripe-webhook', ?, ?)
535
+ `).run(now, now, now);
536
+ mockConstructEvent.mockReturnValue({
537
+ id: 'evt_incomplete_active_account',
538
+ type: 'customer.subscription.updated',
539
+ data: {
540
+ object: {
541
+ id: 'sub_123',
542
+ customer: 'cus_123',
543
+ status: 'incomplete',
544
+ trial_end: null,
545
+ metadata: { planVersionId: 'pro-v1' },
546
+ items: {
547
+ data: [{
548
+ current_period_end: Math.floor(Date.now() / 1000) + 86400,
549
+ price: { id: 'price_pro_month', recurring: { interval: 'month' } }
550
+ }]
551
+ }
552
+ }
553
+ }
554
+ });
555
+ const req = createMockReq('{"id":"evt_incomplete_active_account"}', {
556
+ 'x-taskforce-runtime-mode': 'cloud',
557
+ 'stripe-signature': 'sig'
558
+ });
559
+ const res = createMockRes();
560
+ await webhookHandler(req, res, {});
561
+ expect(res.statusCode).toBe(200);
562
+ const entitlement = db.prepare(`
563
+ SELECT entitlement_state, updated_by
564
+ FROM account_entitlements
565
+ WHERE account_id = 'u1' AND tenant_id = 'default'
566
+ LIMIT 1
567
+ `).get();
568
+ expect(entitlement).toEqual(expect.objectContaining({
569
+ entitlement_state: 'active',
570
+ updated_by: 'stripe-webhook'
571
+ }));
572
+ const billing = db.prepare(`
573
+ SELECT entitlement_state, stripe_status
574
+ FROM user_billing
575
+ WHERE user_id = 'u1' AND tenant_id = 'default'
576
+ LIMIT 1
577
+ `).get();
578
+ expect(billing).toEqual(expect.objectContaining({
579
+ entitlement_state: 'active',
580
+ stripe_status: 'incomplete'
581
+ }));
582
+ });
583
+ it('ignores superseded subscription webhook events for a stale checkout plan selection', async () => {
584
+ const now = new Date().toISOString();
585
+ db.prepare(`
586
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
587
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
588
+ `).run(now, now);
589
+ db.prepare(`
590
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
591
+ VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
592
+ `).run(now, now);
593
+ db.prepare(`
594
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
595
+ VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
596
+ `).run(now, now);
597
+ db.prepare(`
598
+ INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
599
+ VALUES ('u1', 'default', 'cus_123', 'checkout_pending', ?, ?)
600
+ `).run(now, now);
601
+ db.prepare(`
602
+ INSERT INTO account_entitlements (
603
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
604
+ ) VALUES ('u1', 'default', 'team-v1', 'checkout_pending', ?, ?, 'checkout-start', ?, ?)
605
+ `).run(now, new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
606
+ mockConstructEvent.mockReturnValue({
607
+ id: 'evt_stale_checkout_superseded',
608
+ type: 'customer.subscription.updated',
609
+ data: {
610
+ object: {
611
+ id: 'sub_123',
612
+ customer: 'cus_123',
613
+ status: 'active',
614
+ trial_end: null,
615
+ metadata: { planVersionId: 'pro-v1' },
616
+ items: {
617
+ data: [{
618
+ current_period_end: Math.floor(Date.now() / 1000) + 86400,
619
+ price: { id: 'price_pro_month', recurring: { interval: 'month' } }
620
+ }]
621
+ }
622
+ }
623
+ }
624
+ });
625
+ const req = createMockReq('{"id":"evt_stale_checkout_superseded"}', {
626
+ 'x-taskforce-runtime-mode': 'cloud',
627
+ 'stripe-signature': 'sig'
628
+ });
629
+ const res = createMockRes();
630
+ await webhookHandler(req, res, {});
631
+ expect(res.statusCode).toBe(200);
632
+ const entitlement = db.prepare(`
633
+ SELECT plan_version_id, entitlement_state
634
+ FROM account_entitlements
635
+ WHERE account_id = 'u1' AND tenant_id = 'default'
636
+ LIMIT 1
637
+ `).get();
638
+ expect(entitlement).toEqual(expect.objectContaining({
639
+ plan_version_id: 'team-v1',
640
+ entitlement_state: 'checkout_pending'
641
+ }));
642
+ const failure = db.prepare(`
643
+ SELECT reason
644
+ FROM stripe_webhook_failures
645
+ WHERE event_id = 'evt_stale_checkout_superseded'
646
+ ORDER BY created_at DESC
647
+ LIMIT 1
648
+ `).get();
649
+ expect(String(failure?.reason || '')).toContain('no longer current for this account');
650
+ });
651
+ it('ignores stale subscription webhooks for active accounts when a newer checkout attempt is open', async () => {
652
+ const now = new Date().toISOString();
653
+ db.prepare(`
654
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
655
+ VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
656
+ `).run(now, now);
657
+ db.prepare(`
658
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
659
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
660
+ `).run(now, now);
661
+ db.prepare(`
662
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
663
+ VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
664
+ `).run(now, now);
665
+ db.prepare(`
666
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
667
+ VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
668
+ `).run(now, now);
669
+ db.prepare(`
670
+ INSERT INTO user_billing (
671
+ user_id, tenant_id, stripe_customer_id, tier, entitlement_state, created_at, updated_at
672
+ ) VALUES ('u1', 'default', 'cus_123', 'starter', 'active', ?, ?)
673
+ `).run(now, now);
674
+ db.prepare(`
675
+ INSERT INTO account_entitlements (
676
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
677
+ ) VALUES ('u1', 'default', 'starter-v1', 'active', ?, 'free-plan-selection', ?, ?)
678
+ `).run(now, now, now);
679
+ db.prepare(`
680
+ INSERT INTO billing_checkout_attempts (
681
+ session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
682
+ ) VALUES ('cs_newer', 'default', 'u1', 'team-v1', 'month', 'open', ?, ?, ?)
683
+ `).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
684
+ mockConstructEvent.mockReturnValue({
685
+ id: 'evt_active_stale_checkout',
686
+ type: 'customer.subscription.updated',
687
+ data: {
688
+ object: {
689
+ id: 'sub_stale',
690
+ customer: 'cus_123',
691
+ status: 'active',
692
+ trial_end: null,
693
+ metadata: { planVersionId: 'pro-v1' },
694
+ items: {
695
+ data: [{
696
+ current_period_end: Math.floor(Date.now() / 1000) + 86400,
697
+ price: { id: 'price_pro_month', recurring: { interval: 'month' } }
698
+ }]
699
+ }
700
+ }
701
+ }
702
+ });
703
+ const req = createMockReq('{"id":"evt_active_stale_checkout"}', {
704
+ 'x-taskforce-runtime-mode': 'cloud',
705
+ 'stripe-signature': 'sig'
706
+ });
707
+ const res = createMockRes();
708
+ await webhookHandler(req, res, {});
709
+ expect(res.statusCode).toBe(200);
710
+ const entitlement = db.prepare(`
711
+ SELECT plan_version_id, entitlement_state
712
+ FROM account_entitlements
713
+ WHERE account_id = 'u1' AND tenant_id = 'default'
714
+ LIMIT 1
715
+ `).get();
716
+ expect(entitlement).toEqual(expect.objectContaining({
717
+ plan_version_id: 'starter-v1',
718
+ entitlement_state: 'active'
719
+ }));
720
+ const failure = db.prepare(`
721
+ SELECT reason
722
+ FROM stripe_webhook_failures
723
+ WHERE event_id = 'evt_active_stale_checkout'
724
+ ORDER BY created_at DESC
725
+ LIMIT 1
726
+ `).get();
727
+ expect(String(failure?.reason || '')).toContain('no longer current for this account');
728
+ });
424
729
  it('purges expired webhook failures during webhook processing', async () => {
425
730
  const past = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
426
731
  const now = new Date().toISOString();
@@ -558,7 +863,7 @@ describe('Billing Webhook Routes', () => {
558
863
  expect(args.customer).toBe('cus_existing');
559
864
  expect(args.customer_email).toBeUndefined();
560
865
  });
561
- it('reuses an existing Stripe customer discovered by account email before creating checkout', async () => {
866
+ it('records checkout_pending when a blocked account starts a paid checkout', async () => {
562
867
  const now = new Date().toISOString();
563
868
  db.prepare(`
564
869
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -568,10 +873,11 @@ describe('Billing Webhook Routes', () => {
568
873
  INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
569
874
  VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
570
875
  `).run(now, now);
571
- mockCustomersList.mockResolvedValueOnce({
572
- data: [{ id: 'cus_email_match' }]
573
- });
574
- mockSubscriptionList.mockResolvedValueOnce({ data: [] });
876
+ db.prepare(`
877
+ INSERT INTO account_entitlements (
878
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
879
+ ) VALUES ('u1', 'default', 'starter-v1', 'pending_plan_selection', ?, ?, ?)
880
+ `).run(now, now, now);
575
881
  const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
576
882
  const req = createMockReq(JSON.stringify({
577
883
  planVersionId: 'collab-v1',
@@ -583,18 +889,186 @@ describe('Billing Webhook Routes', () => {
583
889
  const res = createMockRes();
584
890
  await checkoutHandler(req, res, {});
585
891
  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
- });
590
- it('builds checkout return URLs from explicit local proxy origin headers', async () => {
591
- const now = new Date().toISOString();
592
- db.prepare(`
593
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
594
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
595
- `).run(now, now);
596
- db.prepare(`
597
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
892
+ expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
893
+ checkoutState: 'checkout',
894
+ id: 'cs_test'
895
+ }));
896
+ const entitlement = db.prepare(`
897
+ SELECT plan_version_id, entitlement_state, updated_by
898
+ FROM account_entitlements
899
+ WHERE tenant_id = 'default' AND account_id = 'u1'
900
+ LIMIT 1
901
+ `).get();
902
+ expect(entitlement).toEqual(expect.objectContaining({
903
+ plan_version_id: 'collab-v1',
904
+ entitlement_state: 'checkout_pending',
905
+ updated_by: 'checkout-start'
906
+ }));
907
+ const billing = db.prepare(`
908
+ SELECT tier, entitlement_state, effective_until
909
+ FROM user_billing
910
+ WHERE tenant_id = 'default' AND user_id = 'u1'
911
+ LIMIT 1
912
+ `).get();
913
+ expect(billing).toEqual(expect.objectContaining({
914
+ tier: 'collab',
915
+ entitlement_state: 'checkout_pending'
916
+ }));
917
+ expect(typeof billing?.effective_until).toBe('string');
918
+ });
919
+ it('does not downgrade an active account to checkout_pending when starting paid checkout', async () => {
920
+ const now = new Date().toISOString();
921
+ db.prepare(`
922
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
923
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
924
+ `).run(now, now);
925
+ db.prepare(`
926
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
927
+ VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
928
+ `).run(now, now);
929
+ db.prepare(`
930
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
931
+ VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
932
+ `).run(now, now);
933
+ db.prepare(`
934
+ INSERT INTO account_entitlements (
935
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
936
+ ) VALUES ('u1', 'default', 'pro-v1', 'active', ?, 'stripe-webhook', ?, ?)
937
+ `).run(now, now, now);
938
+ db.prepare(`
939
+ INSERT INTO user_billing (
940
+ user_id, tenant_id, stripe_customer_id, tier, entitlement_state, created_at, updated_at
941
+ ) VALUES ('u1', 'default', 'cus_existing', 'pro', 'active', ?, ?)
942
+ `).run(now, now);
943
+ const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
944
+ const req = createMockReq(JSON.stringify({
945
+ planVersionId: 'team-v1',
946
+ interval: 'month'
947
+ }), {
948
+ 'x-taskforce-runtime-mode': 'cloud',
949
+ host: 'app.taskforcehq.ai'
950
+ });
951
+ const res = createMockRes();
952
+ await checkoutHandler(req, res, {});
953
+ expect(res.statusCode).toBe(200);
954
+ const entitlement = db.prepare(`
955
+ SELECT plan_version_id, entitlement_state, updated_by
956
+ FROM account_entitlements
957
+ WHERE tenant_id = 'default' AND account_id = 'u1'
958
+ LIMIT 1
959
+ `).get();
960
+ expect(entitlement).toEqual(expect.objectContaining({
961
+ plan_version_id: 'pro-v1',
962
+ entitlement_state: 'active',
963
+ updated_by: 'stripe-webhook'
964
+ }));
965
+ const billing = db.prepare(`
966
+ SELECT tier, entitlement_state
967
+ FROM user_billing
968
+ WHERE tenant_id = 'default' AND user_id = 'u1'
969
+ LIMIT 1
970
+ `).get();
971
+ expect(billing).toEqual(expect.objectContaining({
972
+ tier: 'pro',
973
+ entitlement_state: 'active'
974
+ }));
975
+ });
976
+ it('supersedes a stale checkout_pending selection when the user retries paid checkout', async () => {
977
+ const now = new Date().toISOString();
978
+ const previousExpiry = new Date(Date.now() - (60 * 60 * 1000)).toISOString();
979
+ db.prepare(`
980
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
981
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
982
+ `).run(now, now);
983
+ db.prepare(`
984
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
985
+ VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
986
+ `).run(now, now);
987
+ db.prepare(`
988
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
989
+ VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
990
+ `).run(now, now);
991
+ db.prepare(`
992
+ INSERT INTO account_entitlements (
993
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
994
+ ) VALUES ('u1', 'default', 'pro-v1', 'checkout_pending', ?, ?, 'checkout-start', ?, ?)
995
+ `).run(now, previousExpiry, now, now);
996
+ db.prepare(`
997
+ INSERT INTO user_billing (
998
+ user_id, tenant_id, tier, entitlement_state, effective_until, created_at, updated_at
999
+ ) VALUES ('u1', 'default', 'pro', 'checkout_pending', ?, ?, ?)
1000
+ `).run(previousExpiry, now, now);
1001
+ const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1002
+ const req = createMockReq(JSON.stringify({
1003
+ planVersionId: 'team-v1',
1004
+ interval: 'month'
1005
+ }), {
1006
+ 'x-taskforce-runtime-mode': 'cloud',
1007
+ host: 'app.taskforcehq.ai'
1008
+ });
1009
+ const res = createMockRes();
1010
+ await checkoutHandler(req, res, {});
1011
+ expect(res.statusCode).toBe(200);
1012
+ const entitlement = db.prepare(`
1013
+ SELECT plan_version_id, entitlement_state, effective_until
1014
+ FROM account_entitlements
1015
+ WHERE tenant_id = 'default' AND account_id = 'u1'
1016
+ LIMIT 1
1017
+ `).get();
1018
+ expect(entitlement).toEqual(expect.objectContaining({
1019
+ plan_version_id: 'team-v1',
1020
+ entitlement_state: 'checkout_pending'
1021
+ }));
1022
+ expect(entitlement?.effective_until).not.toBe(previousExpiry);
1023
+ const billing = db.prepare(`
1024
+ SELECT tier, entitlement_state, effective_until
1025
+ FROM user_billing
1026
+ WHERE tenant_id = 'default' AND user_id = 'u1'
1027
+ LIMIT 1
1028
+ `).get();
1029
+ expect(billing).toEqual(expect.objectContaining({
1030
+ tier: 'team',
1031
+ entitlement_state: 'checkout_pending'
1032
+ }));
1033
+ expect(billing?.effective_until).not.toBe(previousExpiry);
1034
+ });
1035
+ it('reuses an existing Stripe customer discovered by account email before creating checkout', async () => {
1036
+ const now = new Date().toISOString();
1037
+ db.prepare(`
1038
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1039
+ VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
1040
+ `).run(now, now);
1041
+ db.prepare(`
1042
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1043
+ VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
1044
+ `).run(now, now);
1045
+ mockCustomersList.mockResolvedValueOnce({
1046
+ data: [{ id: 'cus_email_match' }]
1047
+ });
1048
+ mockSubscriptionList.mockResolvedValueOnce({ data: [] });
1049
+ const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1050
+ const req = createMockReq(JSON.stringify({
1051
+ planVersionId: 'collab-v1',
1052
+ interval: 'month'
1053
+ }), {
1054
+ 'x-taskforce-runtime-mode': 'cloud',
1055
+ host: 'app.taskforcehq.ai'
1056
+ });
1057
+ const res = createMockRes();
1058
+ await checkoutHandler(req, res, {});
1059
+ expect(res.statusCode).toBe(200);
1060
+ const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
1061
+ expect(args.customer).toBe('cus_email_match');
1062
+ expect(args.customer_email).toBeUndefined();
1063
+ });
1064
+ it('builds checkout return URLs from explicit local proxy origin headers', async () => {
1065
+ const now = new Date().toISOString();
1066
+ db.prepare(`
1067
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1068
+ VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
1069
+ `).run(now, now);
1070
+ db.prepare(`
1071
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
598
1072
  VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
599
1073
  `).run(now, now);
600
1074
  const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
@@ -1063,6 +1537,282 @@ describe('Billing Webhook Routes', () => {
1063
1537
  entitlement_state: 'active'
1064
1538
  }));
1065
1539
  });
1540
+ it('does not activate entitlement when the checkout session is still incomplete', async () => {
1541
+ const now = new Date().toISOString();
1542
+ db.prepare(`
1543
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1544
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
1545
+ `).run(now, now);
1546
+ db.prepare(`
1547
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1548
+ VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
1549
+ `).run(now, now);
1550
+ db.prepare(`
1551
+ INSERT INTO account_entitlements (
1552
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
1553
+ ) VALUES ('u1', 'default', 'pro-v1', 'checkout_pending', ?, 'checkout-start', ?, ?)
1554
+ `).run(now, now, now);
1555
+ mockCheckoutRetrieve.mockResolvedValueOnce({
1556
+ id: 'cs_open_123',
1557
+ mode: 'subscription',
1558
+ status: 'open',
1559
+ payment_status: 'unpaid',
1560
+ customer: 'cus_123',
1561
+ subscription: 'sub_123',
1562
+ metadata: { userId: 'u1' }
1563
+ });
1564
+ const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
1565
+ const req = createMockReq(JSON.stringify({
1566
+ sessionId: 'cs_open_123'
1567
+ }), {
1568
+ 'x-taskforce-runtime-mode': 'cloud',
1569
+ host: 'app.taskforcehq.ai'
1570
+ });
1571
+ const res = createMockRes();
1572
+ await confirmHandler(req, res, {});
1573
+ expect(res.statusCode).toBe(409);
1574
+ expect(JSON.parse(res.body)).toEqual({
1575
+ error: 'Checkout session has not completed yet.',
1576
+ code: 'CHECKOUT_SESSION_INCOMPLETE'
1577
+ });
1578
+ expect(mockSubscriptionRetrieve).not.toHaveBeenCalledWith('sub_123');
1579
+ const entitlement = db.prepare(`
1580
+ SELECT plan_version_id, entitlement_state, updated_by
1581
+ FROM account_entitlements
1582
+ WHERE tenant_id = 'default' AND account_id = 'u1'
1583
+ LIMIT 1
1584
+ `).get();
1585
+ expect(entitlement).toEqual(expect.objectContaining({
1586
+ plan_version_id: 'pro-v1',
1587
+ entitlement_state: 'checkout_pending',
1588
+ updated_by: 'checkout-start'
1589
+ }));
1590
+ });
1591
+ it('rejects checkout confirmation when a newer pending checkout superseded the session plan', async () => {
1592
+ const now = new Date().toISOString();
1593
+ db.prepare(`
1594
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1595
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
1596
+ `).run(now, now);
1597
+ db.prepare(`
1598
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1599
+ VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
1600
+ `).run(now, now);
1601
+ db.prepare(`
1602
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1603
+ VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
1604
+ `).run(now, now);
1605
+ db.prepare(`
1606
+ INSERT INTO account_entitlements (
1607
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
1608
+ ) VALUES ('u1', 'default', 'team-v1', 'checkout_pending', ?, ?, 'checkout-start', ?, ?)
1609
+ `).run(now, new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
1610
+ const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
1611
+ const req = createMockReq(JSON.stringify({
1612
+ sessionId: 'cs_stale_123'
1613
+ }), {
1614
+ 'x-taskforce-runtime-mode': 'cloud',
1615
+ host: 'app.taskforcehq.ai'
1616
+ });
1617
+ const res = createMockRes();
1618
+ await confirmHandler(req, res, {});
1619
+ expect(res.statusCode).toBe(409);
1620
+ expect(JSON.parse(res.body)).toEqual({
1621
+ error: 'This checkout session is no longer current for this account.',
1622
+ code: 'CHECKOUT_SESSION_SUPERSEDED'
1623
+ });
1624
+ const entitlement = db.prepare(`
1625
+ SELECT plan_version_id, entitlement_state
1626
+ FROM account_entitlements
1627
+ WHERE tenant_id = 'default' AND account_id = 'u1'
1628
+ LIMIT 1
1629
+ `).get();
1630
+ expect(entitlement).toEqual(expect.objectContaining({
1631
+ plan_version_id: 'team-v1',
1632
+ entitlement_state: 'checkout_pending'
1633
+ }));
1634
+ });
1635
+ it('records the latest active-account checkout attempt and rejects confirmation for a superseded session', async () => {
1636
+ const now = new Date().toISOString();
1637
+ db.prepare(`
1638
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1639
+ VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
1640
+ `).run(now, now);
1641
+ db.prepare(`
1642
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1643
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
1644
+ `).run(now, now);
1645
+ db.prepare(`
1646
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1647
+ VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
1648
+ `).run(now, now);
1649
+ db.prepare(`
1650
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1651
+ VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
1652
+ `).run(now, now);
1653
+ db.prepare(`
1654
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1655
+ VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
1656
+ `).run(now, now);
1657
+ db.prepare(`
1658
+ INSERT INTO user_billing (
1659
+ user_id, tenant_id, tier, entitlement_state, created_at, updated_at
1660
+ ) VALUES ('u1', 'default', 'starter', 'active', ?, ?)
1661
+ `).run(now, now);
1662
+ db.prepare(`
1663
+ INSERT INTO account_entitlements (
1664
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
1665
+ ) VALUES ('u1', 'default', 'starter-v1', 'active', ?, 'free-plan-selection', ?, ?)
1666
+ `).run(now, now, now);
1667
+ mockCheckoutCreate
1668
+ .mockResolvedValueOnce({
1669
+ id: 'cs_old',
1670
+ url: 'https://example.test/checkout/old',
1671
+ expires_at: Math.floor(Date.now() / 1000) + (24 * 60 * 60)
1672
+ })
1673
+ .mockResolvedValueOnce({
1674
+ id: 'cs_new',
1675
+ url: 'https://example.test/checkout/new',
1676
+ expires_at: Math.floor(Date.now() / 1000) + (24 * 60 * 60)
1677
+ });
1678
+ const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1679
+ const firstReq = createMockReq(JSON.stringify({
1680
+ planVersionId: 'pro-v1',
1681
+ interval: 'month'
1682
+ }), {
1683
+ 'x-taskforce-runtime-mode': 'cloud',
1684
+ host: 'app.taskforcehq.ai'
1685
+ });
1686
+ const firstRes = createMockRes();
1687
+ await checkoutHandler(firstReq, firstRes, {});
1688
+ expect(firstRes.statusCode).toBe(200);
1689
+ const secondReq = createMockReq(JSON.stringify({
1690
+ planVersionId: 'team-v1',
1691
+ interval: 'month'
1692
+ }), {
1693
+ 'x-taskforce-runtime-mode': 'cloud',
1694
+ host: 'app.taskforcehq.ai'
1695
+ });
1696
+ const secondRes = createMockRes();
1697
+ await checkoutHandler(secondReq, secondRes, {});
1698
+ expect(secondRes.statusCode).toBe(200);
1699
+ const attempts = db.prepare(`
1700
+ SELECT session_id, plan_version_id, status
1701
+ FROM billing_checkout_attempts
1702
+ WHERE tenant_id = 'default' AND user_id = 'u1'
1703
+ ORDER BY session_id ASC
1704
+ `).all();
1705
+ expect(attempts).toEqual([
1706
+ { session_id: 'cs_new', plan_version_id: 'team-v1', status: 'open' },
1707
+ { session_id: 'cs_old', plan_version_id: 'pro-v1', status: 'superseded' }
1708
+ ]);
1709
+ mockCheckoutRetrieve.mockResolvedValueOnce({
1710
+ id: 'cs_old',
1711
+ mode: 'subscription',
1712
+ status: 'complete',
1713
+ payment_status: 'paid',
1714
+ customer: 'cus_123',
1715
+ subscription: 'sub_old',
1716
+ metadata: { userId: 'u1', planVersionId: 'pro-v1' }
1717
+ });
1718
+ mockSubscriptionRetrieve.mockResolvedValueOnce({
1719
+ id: 'sub_old',
1720
+ customer: 'cus_123',
1721
+ status: 'active',
1722
+ trial_end: null,
1723
+ metadata: { userId: 'u1', planVersionId: 'pro-v1' },
1724
+ items: {
1725
+ data: [{
1726
+ id: 'si_old',
1727
+ current_period_end: Math.floor(Date.now() / 1000) + 86400,
1728
+ price: { id: 'price_pro_month', recurring: { interval: 'month' } }
1729
+ }]
1730
+ }
1731
+ });
1732
+ const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
1733
+ const confirmReq = createMockReq(JSON.stringify({
1734
+ sessionId: 'cs_old'
1735
+ }), {
1736
+ 'x-taskforce-runtime-mode': 'cloud',
1737
+ host: 'app.taskforcehq.ai'
1738
+ });
1739
+ const confirmRes = createMockRes();
1740
+ await confirmHandler(confirmReq, confirmRes, {});
1741
+ expect(confirmRes.statusCode).toBe(409);
1742
+ expect(JSON.parse(confirmRes.body)).toEqual({
1743
+ error: 'This checkout session is no longer current for this account.',
1744
+ code: 'CHECKOUT_SESSION_SUPERSEDED'
1745
+ });
1746
+ });
1747
+ it('ignores subscription webhooks for a stale subscription after a newer checkout already won', async () => {
1748
+ const now = new Date().toISOString();
1749
+ db.prepare(`
1750
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1751
+ VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
1752
+ `).run(now, now);
1753
+ db.prepare(`
1754
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1755
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
1756
+ `).run(now, now);
1757
+ db.prepare(`
1758
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1759
+ VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
1760
+ `).run(now, now);
1761
+ db.prepare(`
1762
+ INSERT INTO user_billing (
1763
+ user_id, tenant_id, stripe_customer_id, stripe_subscription_id, tier, entitlement_state, created_at, updated_at
1764
+ ) VALUES ('u1', 'default', 'cus_123', 'sub_current', 'team', 'active', ?, ?)
1765
+ `).run(now, now);
1766
+ db.prepare(`
1767
+ INSERT INTO account_entitlements (
1768
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
1769
+ ) VALUES ('u1', 'default', 'team-v1', 'active', ?, 'checkout-confirm', ?, ?)
1770
+ `).run(now, now, now);
1771
+ mockConstructEvent.mockReturnValue({
1772
+ id: 'evt_stale_subscription_after_confirm',
1773
+ type: 'customer.subscription.updated',
1774
+ data: {
1775
+ object: {
1776
+ id: 'sub_old',
1777
+ customer: 'cus_123',
1778
+ status: 'active',
1779
+ trial_end: null,
1780
+ metadata: { planVersionId: 'pro-v1' },
1781
+ items: {
1782
+ data: [{
1783
+ current_period_end: Math.floor(Date.now() / 1000) + 86400,
1784
+ price: { id: 'price_pro_month', recurring: { interval: 'month' } }
1785
+ }]
1786
+ }
1787
+ }
1788
+ }
1789
+ });
1790
+ const req = createMockReq('{"id":"evt_stale_subscription_after_confirm"}', {
1791
+ 'x-taskforce-runtime-mode': 'cloud',
1792
+ 'stripe-signature': 'sig'
1793
+ });
1794
+ const res = createMockRes();
1795
+ await webhookHandler(req, res, {});
1796
+ expect(res.statusCode).toBe(200);
1797
+ const entitlement = db.prepare(`
1798
+ SELECT plan_version_id, entitlement_state
1799
+ FROM account_entitlements
1800
+ WHERE account_id = 'u1' AND tenant_id = 'default'
1801
+ LIMIT 1
1802
+ `).get();
1803
+ expect(entitlement).toEqual(expect.objectContaining({
1804
+ plan_version_id: 'team-v1',
1805
+ entitlement_state: 'active'
1806
+ }));
1807
+ const failure = db.prepare(`
1808
+ SELECT reason
1809
+ FROM stripe_webhook_failures
1810
+ WHERE event_id = 'evt_stale_subscription_after_confirm'
1811
+ ORDER BY created_at DESC
1812
+ LIMIT 1
1813
+ `).get();
1814
+ expect(String(failure?.reason || '')).toContain('no longer current for this account');
1815
+ });
1066
1816
  it('lists webhook failure diagnostics for system admin', async () => {
1067
1817
  const now = new Date().toISOString();
1068
1818
  const old = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
@@ -1092,6 +1842,49 @@ describe('Billing Webhook Routes', () => {
1092
1842
  reason: 'diagnostic-1'
1093
1843
  }));
1094
1844
  });
1845
+ it('lists checkout attempt diagnostics for system admin and purges old terminal rows', async () => {
1846
+ const now = new Date().toISOString();
1847
+ const stale = new Date(Date.now() - 20 * 24 * 60 * 60 * 1000).toISOString();
1848
+ db.prepare(`
1849
+ INSERT INTO billing_checkout_attempts (
1850
+ session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1851
+ ) VALUES ('cs_keep_open', 'default', 'u1', 'team-v1', 'month', 'open', ?, ?, ?)
1852
+ `).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
1853
+ db.prepare(`
1854
+ INSERT INTO billing_checkout_attempts (
1855
+ session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1856
+ ) VALUES ('cs_purge_old', 'default', 'u1', 'pro-v1', 'month', 'superseded', ?, ?, ?)
1857
+ `).run(stale, stale, stale);
1858
+ db.prepare(`
1859
+ INSERT INTO billing_checkout_attempts (
1860
+ session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1861
+ ) VALUES ('cs_expire_now', 'default', 'u2', 'pro-v1', 'month', 'open', ?, ?, ?)
1862
+ `).run(new Date(Date.now() - 60 * 1000).toISOString(), now, now);
1863
+ const route = routeHandlers.get('GET /api/taskforce/admin/billing/checkout-attempts');
1864
+ expect(route).toBeTypeOf('function');
1865
+ const req = createMockReq('', {
1866
+ 'x-taskforce-runtime-mode': 'cloud'
1867
+ });
1868
+ req.url = '/api/taskforce/admin/billing/checkout-attempts?limit=10';
1869
+ const res = createMockRes();
1870
+ await route(req, res, {});
1871
+ expect(res.statusCode).toBe(200);
1872
+ const payload = JSON.parse(res.body);
1873
+ expect(payload.purgedTerminalRows).toBeGreaterThanOrEqual(1);
1874
+ expect(payload.expiredRowsNormalized).toBeGreaterThanOrEqual(1);
1875
+ expect(payload.openCount).toBe(1);
1876
+ expect(payload.rows).toEqual(expect.arrayContaining([
1877
+ expect.objectContaining({ session_id: 'cs_keep_open', status: 'open' }),
1878
+ expect.objectContaining({ session_id: 'cs_expire_now', status: 'expired' })
1879
+ ]));
1880
+ const purged = db.prepare(`
1881
+ SELECT session_id
1882
+ FROM billing_checkout_attempts
1883
+ WHERE session_id = 'cs_purge_old'
1884
+ LIMIT 1
1885
+ `).get();
1886
+ expect(purged).toBeUndefined();
1887
+ });
1095
1888
  it('lists stripe price options filtered by interval for system admin', async () => {
1096
1889
  mockPricesList.mockResolvedValueOnce({
1097
1890
  data: [
@@ -1329,6 +2122,18 @@ describe('Billing Webhook Routes', () => {
1329
2122
  await route(req, res, {});
1330
2123
  expect(res.statusCode).toBe(403);
1331
2124
  });
2125
+ it('rejects checkout attempt diagnostics for non-admin', async () => {
2126
+ const nonAdminHandlers = buildBillingRouteHandlers(db, { isAdmin: false });
2127
+ const route = nonAdminHandlers.get('GET /api/taskforce/admin/billing/checkout-attempts');
2128
+ expect(route).toBeTypeOf('function');
2129
+ const req = createMockReq('', {
2130
+ 'x-taskforce-runtime-mode': 'cloud'
2131
+ });
2132
+ req.url = '/api/taskforce/admin/billing/checkout-attempts';
2133
+ const res = createMockRes();
2134
+ await route(req, res, {});
2135
+ expect(res.statusCode).toBe(403);
2136
+ });
1332
2137
  it('returns public pricing catalog with cached Stripe amounts without auth', async () => {
1333
2138
  const now = new Date().toISOString();
1334
2139
  routeHandlers = buildBillingRouteHandlers(db);
@@ -1433,12 +2238,100 @@ describe('Billing Webhook Routes', () => {
1433
2238
  expect(body.planVersionId).toBe('pro-v1');
1434
2239
  expect(body.entitlementState).toBe('active');
1435
2240
  });
1436
- it('reconciles stale entitlement from Stripe when billing status is requested', async () => {
2241
+ it('prefers canonical checkout_pending state over stale billing mirror state in billing status payload', async () => {
2242
+ routeHandlers = buildBillingRouteHandlers(db, {
2243
+ accessStatus: {
2244
+ gate: 'checkout_pending',
2245
+ hasEntitlement: true,
2246
+ canAccessApp: false,
2247
+ canAccessPlans: true,
2248
+ planSelectionRequired: false,
2249
+ message: 'Complete checkout to continue.',
2250
+ entitlement: {
2251
+ accountId: 'u1',
2252
+ planVersionId: 'pro-v1',
2253
+ planId: 'pro',
2254
+ state: 'checkout_pending',
2255
+ effectiveAt: null,
2256
+ effectiveUntil: null
2257
+ }
2258
+ }
2259
+ });
1437
2260
  const now = new Date().toISOString();
1438
2261
  db.prepare(`
1439
2262
  INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
1440
2263
  VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
1441
2264
  `).run(now, now);
2265
+ db.prepare(`
2266
+ INSERT INTO user_billing (
2267
+ user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
2268
+ ) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_pro_month', 'active', 'starter', 'month', 'active', ?, ?)
2269
+ `).run(now, now);
2270
+ const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
2271
+ expect(statusRoute).toBeTypeOf('function');
2272
+ const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2273
+ const res = createMockRes();
2274
+ await statusRoute(req, res, {});
2275
+ expect(res.statusCode).toBe(200);
2276
+ const body = JSON.parse(res.body);
2277
+ expect(body).toEqual(expect.objectContaining({
2278
+ planId: 'pro',
2279
+ planName: 'Pro Plan',
2280
+ planVersionId: 'pro-v1',
2281
+ entitlementState: 'checkout_pending',
2282
+ gate: 'checkout_pending',
2283
+ message: 'Complete checkout to continue.',
2284
+ planSelectionRequired: false,
2285
+ billingConflictCode: null,
2286
+ stripeStatus: 'active',
2287
+ stripeCustomerId: 'cus_123',
2288
+ stripeSubscriptionId: 'sub_123'
2289
+ }));
2290
+ });
2291
+ it('returns checkout conflict code in billing status when the latest attempt expired', async () => {
2292
+ routeHandlers = buildBillingRouteHandlers(db, {
2293
+ accessStatus: {
2294
+ gate: 'plan_selection_required',
2295
+ hasEntitlement: true,
2296
+ canAccessApp: false,
2297
+ canAccessPlans: true,
2298
+ planSelectionRequired: true,
2299
+ message: 'Choose a plan to continue.',
2300
+ entitlement: {
2301
+ accountId: 'u1',
2302
+ planVersionId: 'pro-v1',
2303
+ planId: 'pro',
2304
+ state: 'pending_plan_selection',
2305
+ effectiveAt: null,
2306
+ effectiveUntil: null
2307
+ }
2308
+ }
2309
+ });
2310
+ const now = new Date().toISOString();
2311
+ db.prepare(`
2312
+ INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
2313
+ VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
2314
+ `).run(now, now);
2315
+ db.prepare(`
2316
+ INSERT INTO billing_checkout_attempts (
2317
+ session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
2318
+ ) VALUES ('cs_expired_status', 'default', 'u1', 'pro-v1', 'month', 'expired', ?, ?, ?)
2319
+ `).run(new Date(Date.now() - 60 * 1000).toISOString(), now, now);
2320
+ const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
2321
+ const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2322
+ const res = createMockRes();
2323
+ await statusRoute(req, res, {});
2324
+ expect(res.statusCode).toBe(200);
2325
+ const body = JSON.parse(res.body);
2326
+ expect(body.billingConflictCode).toBe('CHECKOUT_SESSION_EXPIRED');
2327
+ });
2328
+ it('does not reconcile stale billing state from Stripe when billing status is requested', async () => {
2329
+ const now = new Date().toISOString();
2330
+ const stale = new Date(Date.now() - 60_000).toISOString();
2331
+ db.prepare(`
2332
+ INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
2333
+ VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
2334
+ `).run(now, now);
1442
2335
  db.prepare(`
1443
2336
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1444
2337
  VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
@@ -1451,27 +2344,54 @@ describe('Billing Webhook Routes', () => {
1451
2344
  INSERT INTO user_billing (
1452
2345
  user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
1453
2346
  ) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_old', 'active', 'starter', 'month', 'active', ?, ?)
2347
+ `).run(stale, stale);
2348
+ db.prepare(`
2349
+ INSERT INTO account_entitlements (
2350
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
2351
+ ) VALUES ('u1', 'default', 'pro-v1', 'active', ?, ?, ?)
2352
+ `).run(stale, stale, stale);
2353
+ const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
2354
+ const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2355
+ const res = createMockRes();
2356
+ await statusRoute(req, res, {});
2357
+ expect(res.statusCode).toBe(200);
2358
+ expect(mockSubscriptionRetrieve).not.toHaveBeenCalled();
2359
+ expect(mockCustomersList).not.toHaveBeenCalled();
2360
+ expect(mockSubscriptionList).not.toHaveBeenCalled();
2361
+ const body = JSON.parse(res.body);
2362
+ expect(body.planVersionId).toBe('pro-v1');
2363
+ expect(body.planName).toBe('Pro Plan');
2364
+ });
2365
+ it('skips Stripe reconciliation for fresh billing mirror rows when billing status is requested', async () => {
2366
+ const now = new Date().toISOString();
2367
+ db.prepare(`
2368
+ INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
2369
+ VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
2370
+ `).run(now, now);
2371
+ db.prepare(`
2372
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2373
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
2374
+ `).run(now, now);
2375
+ db.prepare(`
2376
+ INSERT INTO user_billing (
2377
+ user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
2378
+ ) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_pro_month', 'active', 'pro', 'month', 'active', ?, ?)
1454
2379
  `).run(now, now);
1455
2380
  db.prepare(`
1456
2381
  INSERT INTO account_entitlements (
1457
2382
  account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
1458
- ) VALUES ('u1', 'default', 'starter-v1', 'active', ?, ?, ?)
2383
+ ) VALUES ('u1', 'default', 'pro-v1', 'active', ?, ?, ?)
1459
2384
  `).run(now, now, now);
1460
2385
  const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
1461
2386
  const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
1462
2387
  const res = createMockRes();
1463
2388
  await statusRoute(req, res, {});
1464
2389
  expect(res.statusCode).toBe(200);
1465
- expect(mockSubscriptionRetrieve).toHaveBeenCalledWith('sub_123');
1466
- const entitlement = db.prepare(`
1467
- SELECT plan_version_id
1468
- FROM account_entitlements
1469
- WHERE tenant_id = 'default' AND account_id = 'u1'
1470
- LIMIT 1
1471
- `).get();
1472
- expect(entitlement?.plan_version_id).toBe('pro-v1');
2390
+ expect(mockSubscriptionRetrieve).not.toHaveBeenCalled();
2391
+ expect(mockCustomersList).not.toHaveBeenCalled();
2392
+ expect(mockSubscriptionList).not.toHaveBeenCalled();
1473
2393
  });
1474
- it('repairs billing status from an active Stripe subscription discovered by account email', async () => {
2394
+ it('does not discover Stripe subscriptions by account email when billing status is requested', async () => {
1475
2395
  const now = new Date().toISOString();
1476
2396
  db.prepare(`
1477
2397
  INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
@@ -1509,11 +2429,13 @@ describe('Billing Webhook Routes', () => {
1509
2429
  const res = createMockRes();
1510
2430
  await statusRoute(req, res, {});
1511
2431
  expect(res.statusCode).toBe(200);
2432
+ expect(mockCustomersList).not.toHaveBeenCalled();
2433
+ expect(mockSubscriptionList).not.toHaveBeenCalled();
1512
2434
  const body = JSON.parse(res.body);
1513
2435
  expect(body.planId).toBe('pro');
1514
2436
  expect(body.planVersionId).toBe('pro-v1');
1515
- expect(body.stripeCustomerId).toBe('cus_email_match');
1516
- expect(body.stripeSubscriptionId).toBe('sub_email_match');
2437
+ expect(body.stripeCustomerId).toBeNull();
2438
+ expect(body.stripeSubscriptionId).toBeNull();
1517
2439
  });
1518
2440
  it('prefers subscription metadata over shared price fallback during webhook processing', async () => {
1519
2441
  const now = new Date().toISOString();