@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
@@ -6,6 +6,7 @@ const BILLING_CONFIG_KEY_PLAN = 'billing.config.v1';
6
6
  const PRICE_METADATA_PLAN_VERSION = 'taskforce_plan_version_id';
7
7
  const BILLING_DIAGNOSTIC_RETENTION_DAYS = 30;
8
8
  const BILLING_DIAGNOSTIC_MAX_PAYLOAD_BYTES = 8 * 1024;
9
+ const BILLING_CHECKOUT_ATTEMPT_RETENTION_DAYS = 14;
9
10
  const BILLING_STATUS_RECONCILIATION_TTL_MS = 30_000;
10
11
  const TASKFORCE_PROXY_ORIGIN_HOST_HEADER = 'x-taskforce-origin-host';
11
12
  const TASKFORCE_PROXY_ORIGIN_PROTO_HEADER = 'x-taskforce-origin-proto';
@@ -26,6 +27,15 @@ function isoFromUnixSeconds(raw) {
26
27
  return null;
27
28
  return new Date(Math.floor(value) * 1000).toISOString();
28
29
  }
30
+ function isIsoTimestampExpired(valueRaw) {
31
+ const value = String(valueRaw || '').trim();
32
+ if (!value)
33
+ return false;
34
+ const timestamp = Date.parse(value);
35
+ if (!Number.isFinite(timestamp))
36
+ return false;
37
+ return timestamp <= Date.now();
38
+ }
29
39
  function parseInterval(raw) {
30
40
  const value = String(raw || '').trim().toLowerCase();
31
41
  if (value === 'month' || value === 'year')
@@ -230,6 +240,8 @@ function mapStripeStatusToCanonicalState(statusRaw) {
230
240
  return 'active';
231
241
  if (status === 'past_due')
232
242
  return 'grace';
243
+ if (status === 'incomplete')
244
+ return 'checkout_pending';
233
245
  return 'canceled';
234
246
  }
235
247
  function isMissingStripeCustomerError(error) {
@@ -474,14 +486,304 @@ function getEntitlementRow(core, userIdRaw) {
474
486
  const db = core.getDatabaseAdapter();
475
487
  const tenantId = core.getTenantId();
476
488
  const row = db.prepare(`
477
- SELECT account_id, plan_version_id, entitlement_state
489
+ SELECT account_id, plan_version_id, entitlement_state, effective_until, updated_by
478
490
  FROM account_entitlements
479
491
  WHERE tenant_id = ? AND account_id = ?
480
492
  LIMIT 1
481
493
  `).get(tenantId, userId);
482
494
  return row || null;
483
495
  }
496
+ function updateCheckoutAttemptStatus(core, sessionIdRaw, status) {
497
+ const sessionId = String(sessionIdRaw || '').trim();
498
+ if (!sessionId)
499
+ return;
500
+ const db = core.getDatabaseAdapter();
501
+ const tenantId = core.getTenantId();
502
+ db.prepare(`
503
+ UPDATE billing_checkout_attempts
504
+ SET status = ?, updated_at = ?
505
+ WHERE tenant_id = ? AND session_id = ?
506
+ `).run(status, new Date().toISOString(), tenantId, sessionId);
507
+ }
508
+ function expireOpenCheckoutAttempts(core) {
509
+ const db = core.getDatabaseAdapter();
510
+ const tenantId = core.getTenantId();
511
+ const now = new Date().toISOString();
512
+ const result = db.prepare(`
513
+ UPDATE billing_checkout_attempts
514
+ SET status = 'expired', updated_at = ?
515
+ WHERE tenant_id = ?
516
+ AND status = 'open'
517
+ AND expires_at IS NOT NULL
518
+ AND expires_at <> ''
519
+ AND expires_at <= ?
520
+ `).run(now, tenantId, now);
521
+ return Number(result?.changes || 0);
522
+ }
523
+ function purgeTerminalCheckoutAttempts(core) {
524
+ const db = core.getDatabaseAdapter();
525
+ const tenantId = core.getTenantId();
526
+ const cutoff = new Date(Date.now() - BILLING_CHECKOUT_ATTEMPT_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString();
527
+ const result = db.prepare(`
528
+ DELETE FROM billing_checkout_attempts
529
+ WHERE tenant_id = ?
530
+ AND status IN ('confirmed', 'superseded', 'expired')
531
+ AND updated_at <= ?
532
+ `).run(tenantId, cutoff);
533
+ return Number(result?.changes || 0);
534
+ }
535
+ function maintainCheckoutAttempts(core) {
536
+ const expiredRows = expireOpenCheckoutAttempts(core);
537
+ const purgedRows = purgeTerminalCheckoutAttempts(core);
538
+ return { expiredRows, purgedRows };
539
+ }
540
+ function normalizeCheckoutAttemptRow(core, row) {
541
+ if (!row)
542
+ return null;
543
+ const sessionId = String(row.session_id || '').trim();
544
+ const status = String(row.status || '').trim().toLowerCase();
545
+ if (!sessionId || status !== 'open' || !isIsoTimestampExpired(row.expires_at)) {
546
+ return row;
547
+ }
548
+ updateCheckoutAttemptStatus(core, sessionId, 'expired');
549
+ return {
550
+ ...row,
551
+ status: 'expired',
552
+ updated_at: new Date().toISOString()
553
+ };
554
+ }
555
+ function getCheckoutAttemptRow(core, sessionIdRaw) {
556
+ const sessionId = String(sessionIdRaw || '').trim();
557
+ if (!sessionId)
558
+ return null;
559
+ const db = core.getDatabaseAdapter();
560
+ const tenantId = core.getTenantId();
561
+ const row = db.prepare(`
562
+ SELECT session_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
563
+ FROM billing_checkout_attempts
564
+ WHERE tenant_id = ? AND session_id = ?
565
+ LIMIT 1
566
+ `).get(tenantId, sessionId);
567
+ return normalizeCheckoutAttemptRow(core, row || null);
568
+ }
569
+ function getCurrentOpenCheckoutAttemptForUser(core, userIdRaw) {
570
+ const userId = String(userIdRaw || '').trim();
571
+ if (!userId)
572
+ return null;
573
+ const db = core.getDatabaseAdapter();
574
+ const tenantId = core.getTenantId();
575
+ const rows = db.prepare(`
576
+ SELECT session_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
577
+ FROM billing_checkout_attempts
578
+ WHERE tenant_id = ? AND user_id = ? AND status = 'open'
579
+ ORDER BY created_at DESC, session_id DESC
580
+ `).all(tenantId, userId);
581
+ for (const row of rows) {
582
+ const normalized = normalizeCheckoutAttemptRow(core, row);
583
+ if (String(normalized?.status || '').trim().toLowerCase() === 'open') {
584
+ return normalized;
585
+ }
586
+ }
587
+ return null;
588
+ }
589
+ function getLatestCheckoutAttemptForUser(core, userIdRaw) {
590
+ const userId = String(userIdRaw || '').trim();
591
+ if (!userId)
592
+ return null;
593
+ const db = core.getDatabaseAdapter();
594
+ const tenantId = core.getTenantId();
595
+ const row = db.prepare(`
596
+ SELECT session_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
597
+ FROM billing_checkout_attempts
598
+ WHERE tenant_id = ? AND user_id = ?
599
+ ORDER BY created_at DESC, session_id DESC
600
+ LIMIT 1
601
+ `).get(tenantId, userId);
602
+ return normalizeCheckoutAttemptRow(core, row || null);
603
+ }
604
+ function resolveBillingConflictCode(core, input) {
605
+ const gate = String(input.gate || '').trim().toLowerCase();
606
+ if (!gate || gate === 'ok')
607
+ return null;
608
+ const latestAttempt = getLatestCheckoutAttemptForUser(core, input.userId);
609
+ const attemptStatus = String(latestAttempt?.status || '').trim().toLowerCase();
610
+ if (attemptStatus === 'expired')
611
+ return 'CHECKOUT_SESSION_EXPIRED';
612
+ if (attemptStatus === 'superseded')
613
+ return 'CHECKOUT_SESSION_SUPERSEDED';
614
+ return null;
615
+ }
616
+ function markLatestOpenCheckoutAttemptConfirmed(core, input) {
617
+ const planVersionId = String(input.planVersionId || '').trim();
618
+ if (!planVersionId)
619
+ return;
620
+ const currentAttempt = getCurrentOpenCheckoutAttemptForUser(core, input.userId);
621
+ if (!currentAttempt)
622
+ return;
623
+ const currentPlanVersionId = String(currentAttempt.plan_version_id || '').trim();
624
+ const sessionId = String(currentAttempt.session_id || '').trim();
625
+ if (!sessionId || currentPlanVersionId !== planVersionId)
626
+ return;
627
+ updateCheckoutAttemptStatus(core, sessionId, 'confirmed');
628
+ }
629
+ function recordCheckoutAttempt(core, input) {
630
+ const sessionId = String(input.sessionId || '').trim();
631
+ const userId = String(input.userId || '').trim();
632
+ const planVersionId = String(input.planVersionId || '').trim();
633
+ const billingInterval = parseInterval(input.billingInterval);
634
+ if (!sessionId || !userId || !planVersionId || !billingInterval)
635
+ return;
636
+ const db = core.getDatabaseAdapter();
637
+ const tenantId = core.getTenantId();
638
+ const now = new Date().toISOString();
639
+ const tx = db.transaction(() => {
640
+ db.prepare(`
641
+ UPDATE billing_checkout_attempts
642
+ SET status = 'superseded', updated_at = ?
643
+ WHERE tenant_id = ? AND user_id = ? AND status = 'open' AND session_id <> ?
644
+ `).run(now, tenantId, userId, sessionId);
645
+ db.prepare(`
646
+ INSERT INTO billing_checkout_attempts (
647
+ session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
648
+ ) VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?)
649
+ ON CONFLICT (session_id) DO UPDATE SET
650
+ user_id = excluded.user_id,
651
+ plan_version_id = excluded.plan_version_id,
652
+ billing_interval = excluded.billing_interval,
653
+ status = 'open',
654
+ expires_at = excluded.expires_at,
655
+ updated_at = excluded.updated_at
656
+ `).run(sessionId, tenantId, userId, planVersionId, billingInterval, input.expiresAt || null, now, now);
657
+ });
658
+ tx();
659
+ }
660
+ function resolveCheckoutLifecycleGuard(core, input) {
661
+ const currentOpenAttempt = getCurrentOpenCheckoutAttemptForUser(core, input.userId);
662
+ const checkoutAttempt = input.sessionId
663
+ ? getCheckoutAttemptRow(core, input.sessionId)
664
+ : currentOpenAttempt;
665
+ const attemptStatus = String(checkoutAttempt?.status || '').trim().toLowerCase();
666
+ const attemptPlanVersionId = String(checkoutAttempt?.plan_version_id || '').trim();
667
+ if (attemptStatus === 'expired') {
668
+ return {
669
+ blocked: true,
670
+ code: 'CHECKOUT_SESSION_EXPIRED',
671
+ message: 'This checkout session expired. Choose a plan and start checkout again.'
672
+ };
673
+ }
674
+ if (attemptStatus === 'superseded') {
675
+ return {
676
+ blocked: true,
677
+ code: 'CHECKOUT_SESSION_SUPERSEDED',
678
+ message: 'This checkout session is no longer current for this account.'
679
+ };
680
+ }
681
+ if (attemptStatus === 'open' && attemptPlanVersionId && input.planVersionId && attemptPlanVersionId !== input.planVersionId) {
682
+ return {
683
+ blocked: true,
684
+ code: 'CHECKOUT_SESSION_SUPERSEDED',
685
+ message: 'This checkout session is no longer current for this account.'
686
+ };
687
+ }
688
+ const currentBilling = getBillingMirrorRow(core, input.userId);
689
+ const currentSubscriptionId = String(currentBilling?.stripe_subscription_id || '').trim();
690
+ if (!currentOpenAttempt && input.subscriptionId && currentSubscriptionId && currentSubscriptionId !== input.subscriptionId) {
691
+ return {
692
+ blocked: true,
693
+ code: 'CHECKOUT_SESSION_SUPERSEDED',
694
+ message: 'This checkout session is no longer current for this account.'
695
+ };
696
+ }
697
+ const entitlement = getEntitlementRow(core, input.userId);
698
+ if (!entitlement)
699
+ return { blocked: false };
700
+ const currentState = String(entitlement.entitlement_state || '').trim().toLowerCase();
701
+ const currentPlanVersionId = String(entitlement.plan_version_id || '').trim();
702
+ const updatedBy = String(entitlement.updated_by || '').trim().toLowerCase();
703
+ const expiredPendingCheckout = currentState === 'checkout_pending' && isIsoTimestampExpired(entitlement.effective_until);
704
+ if (expiredPendingCheckout || (currentState === 'pending_plan_selection' && updatedBy === 'checkout-expired')) {
705
+ return {
706
+ blocked: true,
707
+ code: 'CHECKOUT_SESSION_EXPIRED',
708
+ message: 'This checkout session expired. Choose a plan and start checkout again.'
709
+ };
710
+ }
711
+ if (currentState === 'checkout_pending'
712
+ && currentPlanVersionId
713
+ && input.planVersionId
714
+ && currentPlanVersionId !== input.planVersionId) {
715
+ return {
716
+ blocked: true,
717
+ code: 'CHECKOUT_SESSION_SUPERSEDED',
718
+ message: 'This checkout session is no longer current for this account.'
719
+ };
720
+ }
721
+ return { blocked: false };
722
+ }
723
+ function isCommercialStateAppAccessible(stateRaw) {
724
+ const state = String(stateRaw || '').trim().toLowerCase();
725
+ return state === 'active' || state === 'trialing' || state === 'grace';
726
+ }
727
+ function shouldRecordCheckoutPendingState(entitlement) {
728
+ if (!entitlement)
729
+ return true;
730
+ const currentState = String(entitlement.entitlement_state || '').trim().toLowerCase();
731
+ return currentState === 'pending_plan_selection'
732
+ || currentState === 'checkout_pending'
733
+ || currentState === 'canceled';
734
+ }
735
+ function beginCheckoutPending(core, input) {
736
+ const planId = resolvePlanIdForVersion(core, input.planVersionId);
737
+ const updatedBy = input.updatedBy || 'checkout-start';
738
+ const source = input.source || 'checkout-start';
739
+ if (typeof core.transitionCanonicalCommercialState === 'function') {
740
+ core.transitionCanonicalCommercialState({
741
+ accountId: input.userId,
742
+ planVersionId: input.planVersionId,
743
+ state: 'checkout_pending',
744
+ effectiveUntil: input.effectiveUntil || null,
745
+ billingMirrorPlanId: planId,
746
+ source,
747
+ updatedBy
748
+ });
749
+ return;
750
+ }
751
+ const db = core.getDatabaseAdapter();
752
+ const tenantId = core.getTenantId();
753
+ const now = new Date().toISOString();
754
+ db.prepare(`
755
+ INSERT INTO account_entitlements (account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at)
756
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
757
+ ON CONFLICT (account_id) DO UPDATE SET
758
+ plan_version_id = excluded.plan_version_id,
759
+ entitlement_state = excluded.entitlement_state,
760
+ effective_at = excluded.effective_at,
761
+ effective_until = excluded.effective_until,
762
+ updated_by = excluded.updated_by,
763
+ updated_at = excluded.updated_at
764
+ `).run(input.userId, tenantId, input.planVersionId, 'checkout_pending', now, input.effectiveUntil || null, updatedBy, now, now);
765
+ upsertBillingMirror(core, {
766
+ userId: input.userId,
767
+ planId,
768
+ canonicalState: 'checkout_pending',
769
+ effectiveUntil: input.effectiveUntil || null
770
+ });
771
+ }
484
772
  function upsertCanonicalEntitlement(core, input) {
773
+ const updatedBy = input.updatedBy || 'stripe-webhook';
774
+ if (typeof core.transitionCanonicalCommercialState === 'function') {
775
+ core.transitionCanonicalCommercialState({
776
+ accountId: input.userId,
777
+ planVersionId: input.planVersionId,
778
+ state: input.state,
779
+ effectiveAt: input.effectiveAt,
780
+ effectiveUntil: input.effectiveUntil,
781
+ billingMirrorPlanId: resolvePlanIdForVersion(core, input.planVersionId),
782
+ source: updatedBy === 'checkout-confirm' ? 'checkout-confirm' : 'stripe-webhook',
783
+ updatedBy
784
+ });
785
+ return;
786
+ }
485
787
  const db = core.getDatabaseAdapter();
486
788
  const tenantId = core.getTenantId();
487
789
  const now = new Date().toISOString();
@@ -495,7 +797,7 @@ function upsertCanonicalEntitlement(core, input) {
495
797
  effective_until = excluded.effective_until,
496
798
  updated_by = excluded.updated_by,
497
799
  updated_at = excluded.updated_at
498
- `).run(input.userId, tenantId, input.planVersionId, input.state, input.effectiveAt || now, input.effectiveUntil || null, input.updatedBy || 'stripe-webhook', now, now);
800
+ `).run(input.userId, tenantId, input.planVersionId, input.state, input.effectiveAt || now, input.effectiveUntil || null, updatedBy, now, now);
499
801
  }
500
802
  function upsertBillingMirror(core, input) {
501
803
  const db = core.getDatabaseAdapter();
@@ -546,7 +848,12 @@ function applySubscriptionSnapshot(core, input) {
546
848
  const canceledState = stripeStatus === 'canceled'
547
849
  ? (currentPeriodEnd && currentPeriodEnd > nowIso ? 'active' : 'canceled')
548
850
  : null;
549
- const canonicalState = canceledState || mapStripeStatusToCanonicalState(stripeStatus);
851
+ const mappedCanonicalState = canceledState || mapStripeStatusToCanonicalState(stripeStatus);
852
+ const preserveExistingAccess = mappedCanonicalState === 'checkout_pending'
853
+ && isCommercialStateAppAccessible(existingEntitlement?.entitlement_state);
854
+ const canonicalState = preserveExistingAccess
855
+ ? String(existingEntitlement?.entitlement_state || 'active').trim().toLowerCase()
856
+ : mappedCanonicalState;
550
857
  const effectiveUntil = stripeStatus === 'canceled'
551
858
  ? currentPeriodEnd
552
859
  : (currentPeriodEnd || null);
@@ -563,7 +870,17 @@ function applySubscriptionSnapshot(core, input) {
563
870
  currentPeriodEnd,
564
871
  effectiveUntil
565
872
  });
566
- if (planVersionId) {
873
+ if (planVersionId && canonicalState === 'checkout_pending') {
874
+ if (shouldRecordCheckoutPendingState(existingEntitlement)) {
875
+ beginCheckoutPending(core, {
876
+ userId,
877
+ planVersionId,
878
+ source,
879
+ updatedBy: source
880
+ });
881
+ }
882
+ }
883
+ else if (planVersionId) {
567
884
  upsertCanonicalEntitlement(core, {
568
885
  userId,
569
886
  planVersionId: planVersionId || existingEntitlement?.plan_version_id || core.getDefaultPlanVersionIdByMode('personal', { allowFallback: true }),
@@ -934,12 +1251,23 @@ export function registerBillingRoutes(deps) {
934
1251
  return;
935
1252
  }
936
1253
  try {
1254
+ maintainCheckoutAttempts(core);
937
1255
  const userId = requestUserId(req);
938
1256
  const db = core.getDatabaseAdapter();
939
1257
  const tenantId = core.getTenantId();
940
- const billingReconciliation = await reconcileBillingMirrorIfNeeded(core, userId);
941
- const row = billingReconciliation.row;
1258
+ const row = getBillingMirrorRow(core, userId);
942
1259
  const access = core.getAccountAccessStatus(userId);
1260
+ const billingConflictCode = resolveBillingConflictCode(core, {
1261
+ userId,
1262
+ gate: String(access.gate || '')
1263
+ });
1264
+ const resolvedPlanId = String(access.entitlement?.planId || row?.tier || '').trim() || null;
1265
+ const planRow = resolvedPlanId ? db.prepare(`
1266
+ SELECT display_name
1267
+ FROM plan_catalog
1268
+ WHERE tenant_id = ? AND plan_id = ?
1269
+ LIMIT 1
1270
+ `).get(tenantId, resolvedPlanId) : undefined;
943
1271
  if (!access.hasEntitlement || !access.entitlement) {
944
1272
  res.writeHead(200, { 'Content-Type': 'application/json' });
945
1273
  res.end(JSON.stringify({
@@ -951,24 +1279,18 @@ export function registerBillingRoutes(deps) {
951
1279
  trialEnd: row?.trial_end || null,
952
1280
  currentPeriodEnd: row?.current_period_end || null,
953
1281
  effectiveUntil: row?.effective_until || null,
954
- entitlementState: null,
1282
+ entitlementState: row?.entitlement_state || null,
955
1283
  planVersionId: null,
956
- planId: null,
957
- planName: null,
1284
+ planId: resolvedPlanId,
1285
+ planName: planRow?.display_name ? String(planRow.display_name) : resolvedPlanId,
958
1286
  gate: access.gate,
959
1287
  message: access.message,
960
1288
  planSelectionRequired: access.planSelectionRequired,
961
- billingConflictCode: billingReconciliation.conflictCode
1289
+ billingConflictCode
962
1290
  }));
963
1291
  return;
964
1292
  }
965
1293
  const entitlement = access.entitlement;
966
- const planRow = db.prepare(`
967
- SELECT display_name
968
- FROM plan_catalog
969
- WHERE tenant_id = ? AND plan_id = ?
970
- LIMIT 1
971
- `).get(tenantId, entitlement.planId);
972
1294
  res.writeHead(200, { 'Content-Type': 'application/json' });
973
1295
  res.end(JSON.stringify({
974
1296
  stripeStatus: row?.stripe_status || null,
@@ -986,7 +1308,7 @@ export function registerBillingRoutes(deps) {
986
1308
  gate: access.gate,
987
1309
  message: access.message,
988
1310
  planSelectionRequired: access.planSelectionRequired,
989
- billingConflictCode: billingReconciliation.conflictCode
1311
+ billingConflictCode
990
1312
  }));
991
1313
  }
992
1314
  catch (error) {
@@ -1290,6 +1612,68 @@ export function registerBillingRoutes(deps) {
1290
1612
  rows
1291
1613
  }));
1292
1614
  });
1615
+ addRoute('GET', '/api/taskforce/admin/billing/checkout-attempts', async (req, res) => {
1616
+ if (!ensureCloudBilling(req, res))
1617
+ return;
1618
+ if (!ensureAdminRole(req)) {
1619
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1620
+ res.end(JSON.stringify({ error: 'Forbidden: system admin role required.' }));
1621
+ return;
1622
+ }
1623
+ const db = core.getDatabaseAdapter();
1624
+ const tenantId = core.getTenantId();
1625
+ const requestUrl = parseRequestUrl(req);
1626
+ const limit = parsePositiveIntBounded(requestUrl.searchParams.get('limit'), 50, 1, 200);
1627
+ const filterUserId = String(requestUrl.searchParams.get('userId') || '').trim();
1628
+ const now = new Date().toISOString();
1629
+ const maintenance = maintainCheckoutAttempts(core);
1630
+ const rows = filterUserId
1631
+ ? db.prepare(`
1632
+ SELECT session_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1633
+ FROM billing_checkout_attempts
1634
+ WHERE tenant_id = ? AND user_id = ?
1635
+ ORDER BY created_at DESC, session_id DESC
1636
+ LIMIT ?
1637
+ `).all(tenantId, filterUserId, limit)
1638
+ : db.prepare(`
1639
+ SELECT session_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1640
+ FROM billing_checkout_attempts
1641
+ WHERE tenant_id = ?
1642
+ ORDER BY created_at DESC, session_id DESC
1643
+ LIMIT ?
1644
+ `).all(tenantId, limit);
1645
+ const total = filterUserId
1646
+ ? db.prepare(`
1647
+ SELECT COUNT(*) AS count
1648
+ FROM billing_checkout_attempts
1649
+ WHERE tenant_id = ? AND user_id = ?
1650
+ `).get(tenantId, filterUserId)
1651
+ : db.prepare(`
1652
+ SELECT COUNT(*) AS count
1653
+ FROM billing_checkout_attempts
1654
+ WHERE tenant_id = ?
1655
+ `).get(tenantId);
1656
+ const openCount = filterUserId
1657
+ ? db.prepare(`
1658
+ SELECT COUNT(*) AS count
1659
+ FROM billing_checkout_attempts
1660
+ WHERE tenant_id = ? AND user_id = ? AND status = 'open'
1661
+ `).get(tenantId, filterUserId)
1662
+ : db.prepare(`
1663
+ SELECT COUNT(*) AS count
1664
+ FROM billing_checkout_attempts
1665
+ WHERE tenant_id = ? AND status = 'open'
1666
+ `).get(tenantId);
1667
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1668
+ res.end(JSON.stringify({
1669
+ asOf: now,
1670
+ expiredRowsNormalized: maintenance.expiredRows,
1671
+ purgedTerminalRows: maintenance.purgedRows,
1672
+ total: Number(total?.count || 0),
1673
+ openCount: Number(openCount?.count || 0),
1674
+ rows
1675
+ }));
1676
+ });
1293
1677
  addRoute('POST', '/api/taskforce/admin/billing/prices/import', async (req, res) => {
1294
1678
  if (!ensureCloudBilling(req, res))
1295
1679
  return;
@@ -1388,6 +1772,7 @@ export function registerBillingRoutes(deps) {
1388
1772
  return;
1389
1773
  }
1390
1774
  try {
1775
+ maintainCheckoutAttempts(core);
1391
1776
  const userId = requestUserId(req);
1392
1777
  const currentBillingResult = await reconcileBillingMirrorIfNeeded(core, userId);
1393
1778
  const currentBilling = currentBillingResult.row;
@@ -1551,6 +1936,21 @@ export function registerBillingRoutes(deps) {
1551
1936
  billingInterval: interval
1552
1937
  }
1553
1938
  });
1939
+ const checkoutEffectiveUntil = isoFromUnixSeconds(session.expires_at);
1940
+ recordCheckoutAttempt(core, {
1941
+ sessionId: session.id,
1942
+ userId,
1943
+ planVersionId,
1944
+ billingInterval: interval,
1945
+ expiresAt: checkoutEffectiveUntil
1946
+ });
1947
+ if (shouldRecordCheckoutPendingState(currentEntitlement) && !isCommercialStateAppAccessible(currentEntitlement?.entitlement_state)) {
1948
+ beginCheckoutPending(core, {
1949
+ userId,
1950
+ planVersionId,
1951
+ effectiveUntil: checkoutEffectiveUntil
1952
+ });
1953
+ }
1554
1954
  const checkoutUrl = isMockStripeClient(stripe)
1555
1955
  ? buildMockStripeHostedCheckoutUrl(resolveHostedServerBaseUrl(req), session.id)
1556
1956
  : (session.url || null);
@@ -1592,11 +1992,30 @@ export function registerBillingRoutes(deps) {
1592
1992
  return;
1593
1993
  }
1594
1994
  try {
1995
+ maintainCheckoutAttempts(core);
1595
1996
  const stripe = getStripeClient();
1596
1997
  const expectedUserId = requestUserId(req);
1597
1998
  const session = await stripe.checkout.sessions.retrieve(sessionId, {
1598
1999
  expand: ['subscription']
1599
2000
  });
2001
+ const sessionStatus = String(session.status || '').trim().toLowerCase();
2002
+ const paymentStatus = String(session.payment_status || '').trim().toLowerCase();
2003
+ if (session.mode !== 'subscription') {
2004
+ res.writeHead(409, { 'Content-Type': 'application/json' });
2005
+ res.end(JSON.stringify({
2006
+ error: 'Checkout session is not a subscription checkout.',
2007
+ code: 'CHECKOUT_SESSION_INVALID_MODE'
2008
+ }));
2009
+ return;
2010
+ }
2011
+ if (sessionStatus !== 'complete' || (paymentStatus !== 'paid' && paymentStatus !== 'no_payment_required')) {
2012
+ res.writeHead(409, { 'Content-Type': 'application/json' });
2013
+ res.end(JSON.stringify({
2014
+ error: 'Checkout session has not completed yet.',
2015
+ code: 'CHECKOUT_SESSION_INCOMPLETE'
2016
+ }));
2017
+ return;
2018
+ }
1600
2019
  const metadata = session.metadata || {};
1601
2020
  const customerId = String(session.customer || '').trim();
1602
2021
  const subscriptionId = typeof session.subscription === 'string'
@@ -1615,22 +2034,47 @@ export function registerBillingRoutes(deps) {
1615
2034
  res.end(JSON.stringify({ error: 'Checkout session does not belong to the current account.' }));
1616
2035
  return;
1617
2036
  }
2037
+ let resolvedPlanVersionId = null;
2038
+ let subscription = null;
2039
+ if (subscriptionId) {
2040
+ subscription = typeof session.subscription === 'string' || !session.subscription
2041
+ ? await stripe.subscriptions.retrieve(subscriptionId)
2042
+ : session.subscription;
2043
+ const priceId = String(subscription.items?.data?.[0]?.price?.id || '').trim();
2044
+ resolvedPlanVersionId = resolvePlanVersionIdForBillingEvent(core, [
2045
+ subscription.metadata?.planVersionId,
2046
+ subscription.metadata?.plan_version_id,
2047
+ metadata.planVersionId,
2048
+ metadata.plan_version_id
2049
+ ], priceId).planVersionId;
2050
+ const lifecycleGuard = resolveCheckoutLifecycleGuard(core, {
2051
+ userId: resolvedUserId,
2052
+ planVersionId: resolvedPlanVersionId,
2053
+ sessionId,
2054
+ subscriptionId
2055
+ });
2056
+ if (lifecycleGuard.blocked) {
2057
+ res.writeHead(409, { 'Content-Type': 'application/json' });
2058
+ res.end(JSON.stringify({
2059
+ error: lifecycleGuard.message,
2060
+ code: lifecycleGuard.code
2061
+ }));
2062
+ return;
2063
+ }
2064
+ }
1618
2065
  core.createUserBillingRecord(resolvedUserId);
1619
2066
  upsertBillingMirror(core, {
1620
2067
  userId: resolvedUserId,
1621
2068
  stripeCustomerId: customerId || null,
1622
2069
  stripeSubscriptionId: subscriptionId || null
1623
2070
  });
1624
- let resolvedPlanVersionId = null;
1625
- if (session.mode === 'subscription' && subscriptionId) {
1626
- const subscription = typeof session.subscription === 'string' || !session.subscription
1627
- ? await stripe.subscriptions.retrieve(subscriptionId)
1628
- : session.subscription;
2071
+ if (subscription) {
1629
2072
  resolvedPlanVersionId = applySubscriptionSnapshot(core, {
1630
2073
  userId: resolvedUserId,
1631
2074
  subscription,
1632
2075
  source: 'checkout-confirm'
1633
2076
  }).planVersionId;
2077
+ updateCheckoutAttemptStatus(core, sessionId, 'confirmed');
1634
2078
  }
1635
2079
  res.writeHead(200, { 'Content-Type': 'application/json' });
1636
2080
  res.end(JSON.stringify({
@@ -1801,6 +2245,7 @@ export function registerBillingRoutes(deps) {
1801
2245
  const db = core.getDatabaseAdapter();
1802
2246
  const tenantId = core.getTenantId();
1803
2247
  purgeExpiredWebhookFailures(core);
2248
+ maintainCheckoutAttempts(core);
1804
2249
  let duplicate = false;
1805
2250
  const tx = db.transaction(() => {
1806
2251
  const acquired = lockWebhookEvent(core, event);
@@ -1853,11 +2298,31 @@ export function registerBillingRoutes(deps) {
1853
2298
  });
1854
2299
  return;
1855
2300
  }
1856
- applySubscriptionSnapshot(core, {
2301
+ const lifecycleGuard = resolveCheckoutLifecycleGuard(core, {
2302
+ userId,
2303
+ planVersionId,
2304
+ subscriptionId: String(subscription.id || '').trim()
2305
+ });
2306
+ if (lifecycleGuard.blocked) {
2307
+ recordWebhookFailure(core, {
2308
+ eventId: event.id,
2309
+ eventType: event.type,
2310
+ reason: lifecycleGuard.message,
2311
+ payload: event
2312
+ });
2313
+ return;
2314
+ }
2315
+ const snapshot = applySubscriptionSnapshot(core, {
1857
2316
  userId,
1858
2317
  subscription,
1859
2318
  source: 'stripe-webhook'
1860
2319
  });
2320
+ if (snapshot.canonicalState !== 'checkout_pending') {
2321
+ markLatestOpenCheckoutAttemptConfirmed(core, {
2322
+ userId,
2323
+ planVersionId: snapshot.planVersionId
2324
+ });
2325
+ }
1861
2326
  return;
1862
2327
  }
1863
2328
  if (event.type === 'invoice.paid' || event.type === 'invoice.payment_failed') {
@@ -1878,6 +2343,20 @@ export function registerBillingRoutes(deps) {
1878
2343
  invoice.subscription_details?.metadata?.plan_version_id
1879
2344
  ], priceId);
1880
2345
  const planVersionId = planVersionResolution.planVersionId;
2346
+ const lifecycleGuard = resolveCheckoutLifecycleGuard(core, {
2347
+ userId,
2348
+ planVersionId,
2349
+ subscriptionId
2350
+ });
2351
+ if (lifecycleGuard.blocked) {
2352
+ recordWebhookFailure(core, {
2353
+ eventId: event.id,
2354
+ eventType: event.type,
2355
+ reason: lifecycleGuard.message,
2356
+ payload: event
2357
+ });
2358
+ return;
2359
+ }
1881
2360
  const existingEntitlement = getEntitlementRow(core, userId);
1882
2361
  const canonicalState = event.type === 'invoice.payment_failed' ? 'grace' : 'active';
1883
2362
  const currentPeriodEnd = invoice.lines?.data?.[0]?.period?.end
@@ -1913,6 +2392,10 @@ export function registerBillingRoutes(deps) {
1913
2392
  effectiveAt: new Date().toISOString(),
1914
2393
  effectiveUntil: currentPeriodEnd
1915
2394
  });
2395
+ markLatestOpenCheckoutAttemptConfirmed(core, {
2396
+ userId,
2397
+ planVersionId
2398
+ });
1916
2399
  return;
1917
2400
  }
1918
2401
  });