@taskforcehq/taskforce 0.3.302 → 0.3.303

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/Taskforce.module.css +11 -0
  2. package/dist/TaskforceCore.js +59 -9
  3. package/dist/TaskforceCore.test.js +717 -14
  4. package/dist/components/task/TaskKanban.test.js +17 -0
  5. package/dist/components/views/PlansPage.d.ts +3 -1
  6. package/dist/components/views/PlansPage.js +110 -51
  7. package/dist/components/views/PlansPage.test.d.ts +1 -0
  8. package/dist/components/views/PlansPage.test.js +100 -0
  9. package/dist/components/views/StandaloneLayout.d.ts +1 -0
  10. package/dist/components/views/StandaloneLayout.js +106 -151
  11. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +8 -3
  12. package/dist/components/views/standalone/modals/AccountHubModal.js +16 -2
  13. package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +1 -0
  14. package/dist/components/views/standalone/modals/AccountHubModal.test.js +26 -0
  15. package/dist/core/EntitlementsPolicy.test.js +2 -2
  16. package/dist/core/GlobalSettingsService.js +78 -18
  17. package/dist/core/PlanEntitlementService.d.ts +29 -5
  18. package/dist/core/PlanEntitlementService.js +297 -219
  19. package/dist/core/SignupMonetizationSettings.test.js +173 -49
  20. package/dist/core/SystemAdmin.test.js +186 -96
  21. package/dist/core/Taskforce.d.ts +51 -10
  22. package/dist/core/Taskforce.js +218 -49
  23. package/dist/core/Taskforce.mcpAuth.test.js +0 -2
  24. package/dist/core/UserProfileAvatarDrafts.test.js +12 -3
  25. package/dist/core/types.d.ts +11 -12
  26. package/dist/hooks/useSyncOrchestrator.js +17 -3
  27. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +97 -5
  28. package/dist/hooks/useTaskforce.d.ts +3 -0
  29. package/dist/hooks/useTaskforce.js +9 -4
  30. package/dist/mcp/runtime.js +90 -29
  31. package/dist/mcp/runtime.test.js +272 -81
  32. package/dist/migrations/billingSchemaParity.test.js +3 -0
  33. package/dist/migrations/taskSchemaMigrations.js +15 -0
  34. package/dist/server/index.cookieProxy.test.js +1 -0
  35. package/dist/server/index.js +2 -0
  36. package/dist/server/index.test.js +2 -1
  37. package/dist/server/mockStripe.d.ts +1 -0
  38. package/dist/server/mockStripe.js +2 -0
  39. package/dist/server/routes/admin.js +80 -23
  40. package/dist/server/routes/auth.d.ts +1 -0
  41. package/dist/server/routes/auth.js +194 -53
  42. package/dist/server/routes/billing.js +506 -23
  43. package/dist/server/routes/billing.test.js +937 -45
  44. package/dist/server/routes/shared.js +5 -3
  45. package/dist/server/routes/sync.integration.test.js +160 -0
  46. package/dist/server/routes/sync.js +15 -5
  47. package/dist/server/routes.js +3 -3
  48. package/dist/server/routes.test.js +287 -76
  49. package/dist/sync/cloudSyncApi.d.ts +1 -0
  50. package/dist/sync/contentSyncState.d.ts +10 -0
  51. package/dist/sync/contentSyncState.js +277 -16
  52. package/dist/sync/syncService.d.ts +1 -0
  53. package/dist/sync/syncService.js +21 -8
  54. package/dist/sync/workspacePullFeed.d.ts +10 -0
  55. package/dist/sync/workspacePullFeed.js +111 -63
  56. package/dist/sync/workspacePullFeed.test.js +112 -0
  57. package/dist/ui/assets/{AgentsModule-JG5jc3he.js → AgentsModule-CpsTmrIW.js} +1 -1
  58. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-Dvi02Y7q.js → AnnotatedAttachmentWorkspace-C_TmXZwA.js} +1 -1
  59. package/dist/ui/assets/{DocumentWorkspace-iys3zfca.js → DocumentWorkspace-C3GyzrWm.js} +1 -1
  60. package/dist/ui/assets/{InitiativesModule-DJvLJI3A.js → InitiativesModule-4-Rh2K2n.js} +1 -1
  61. package/dist/ui/assets/PlansPage-BP7AYOqr.js +1 -0
  62. package/dist/ui/assets/{TaskSettings-Bqd4cCzW.js → TaskSettings-BOC5F6Ag.js} +1 -1
  63. package/dist/ui/assets/{WorkflowsModule-BguHeQaV.js → WorkflowsModule-CGk9s3NJ.js} +1 -1
  64. package/dist/ui/assets/index-CLIMgR6g.js +6 -0
  65. package/dist/ui/assets/index-DneETxcu.css +1 -0
  66. package/dist/ui/assets/{vendor-icons-C8EED3Oh.js → vendor-icons-pWocEipT.js} +1 -1
  67. package/dist/ui/index.html +3 -3
  68. package/dist/utils/accountProfileSummaryCache.d.ts +31 -0
  69. package/dist/utils/accountProfileSummaryCache.js +97 -0
  70. package/dist/utils/accountProfileSummaryCache.test.d.ts +1 -0
  71. package/dist/utils/accountProfileSummaryCache.test.js +63 -0
  72. package/package.json +3 -3
  73. package/dist/ui/assets/PlansPage-BMPlpFpw.js +0 -1
  74. package/dist/ui/assets/index-MXeWoebC.css +0 -1
  75. package/dist/ui/assets/index-YJ4k5QNa.js +0 -6
  76. package/dist/utils/billingStatusCache.d.ts +0 -21
  77. package/dist/utils/billingStatusCache.js +0 -70
@@ -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,7 +2238,94 @@ 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
+ });
2260
+ const now = new Date().toISOString();
2261
+ db.prepare(`
2262
+ INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
2263
+ VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
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 () => {
1437
2329
  const now = new Date().toISOString();
1438
2330
  const stale = new Date(Date.now() - 60_000).toISOString();
1439
2331
  db.prepare(`
@@ -1456,21 +2348,19 @@ describe('Billing Webhook Routes', () => {
1456
2348
  db.prepare(`
1457
2349
  INSERT INTO account_entitlements (
1458
2350
  account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
1459
- ) VALUES ('u1', 'default', 'starter-v1', 'active', ?, ?, ?)
2351
+ ) VALUES ('u1', 'default', 'pro-v1', 'active', ?, ?, ?)
1460
2352
  `).run(stale, stale, stale);
1461
2353
  const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
1462
2354
  const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
1463
2355
  const res = createMockRes();
1464
2356
  await statusRoute(req, res, {});
1465
2357
  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');
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');
1474
2364
  });
1475
2365
  it('skips Stripe reconciliation for fresh billing mirror rows when billing status is requested', async () => {
1476
2366
  const now = new Date().toISOString();
@@ -1501,7 +2391,7 @@ describe('Billing Webhook Routes', () => {
1501
2391
  expect(mockCustomersList).not.toHaveBeenCalled();
1502
2392
  expect(mockSubscriptionList).not.toHaveBeenCalled();
1503
2393
  });
1504
- 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 () => {
1505
2395
  const now = new Date().toISOString();
1506
2396
  db.prepare(`
1507
2397
  INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
@@ -1539,11 +2429,13 @@ describe('Billing Webhook Routes', () => {
1539
2429
  const res = createMockRes();
1540
2430
  await statusRoute(req, res, {});
1541
2431
  expect(res.statusCode).toBe(200);
2432
+ expect(mockCustomersList).not.toHaveBeenCalled();
2433
+ expect(mockSubscriptionList).not.toHaveBeenCalled();
1542
2434
  const body = JSON.parse(res.body);
1543
2435
  expect(body.planId).toBe('pro');
1544
2436
  expect(body.planVersionId).toBe('pro-v1');
1545
- expect(body.stripeCustomerId).toBe('cus_email_match');
1546
- expect(body.stripeSubscriptionId).toBe('sub_email_match');
2437
+ expect(body.stripeCustomerId).toBeNull();
2438
+ expect(body.stripeSubscriptionId).toBeNull();
1547
2439
  });
1548
2440
  it('prefers subscription metadata over shared price fallback during webhook processing', async () => {
1549
2441
  const now = new Date().toISOString();