@taskforcehq/taskforce 0.3.302 → 0.3.304

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/dist/Taskforce.module.css +113 -5
  2. package/dist/TaskforceCore.js +560 -208
  3. package/dist/TaskforceCore.test.js +1833 -91
  4. package/dist/annotatedAttachments/service.d.ts +1 -1
  5. package/dist/annotatedAttachments/types.d.ts +1 -1
  6. package/dist/components/features/AgentsModule.js +1 -1
  7. package/dist/components/features/AgentsModule.test.js +1 -1
  8. package/dist/components/features/TaskSettings.js +58 -38
  9. package/dist/components/features/TaskSettings.test.js +139 -40
  10. package/dist/components/task/TaskActionHeader.d.ts +2 -1
  11. package/dist/components/task/TaskActionHeader.js +3 -3
  12. package/dist/components/task/TaskActionHeader.test.js +8 -0
  13. package/dist/components/task/TaskCard.js +16 -1
  14. package/dist/components/task/TaskCard.test.js +31 -0
  15. package/dist/components/task/TaskForm.d.ts +2 -1
  16. package/dist/components/task/TaskForm.js +22 -4
  17. package/dist/components/task/TaskForm.test.js +46 -0
  18. package/dist/components/task/TaskKanban.d.ts +1 -1
  19. package/dist/components/task/TaskKanban.js +31 -160
  20. package/dist/components/task/TaskKanban.test.js +17 -0
  21. package/dist/components/task/TaskStatusActions.d.ts +6 -1
  22. package/dist/components/task/TaskStatusActions.js +8 -3
  23. package/dist/components/task/TaskStatusActions.test.js +9 -0
  24. package/dist/components/task/taskKanbanDropRules.d.ts +58 -0
  25. package/dist/components/task/taskKanbanDropRules.js +187 -0
  26. package/dist/components/task/taskKanbanDropRules.test.d.ts +1 -0
  27. package/dist/components/task/taskKanbanDropRules.test.js +114 -0
  28. package/dist/components/ui/Modal.d.ts +1 -1
  29. package/dist/components/ui/Modal.js +1 -0
  30. package/dist/components/views/AppShellHeader.js +5 -1
  31. package/dist/components/views/AppShellHeader.test.js +4 -0
  32. package/dist/components/views/PlanComparisonPage.d.ts +3 -2
  33. package/dist/components/views/PlanComparisonPage.js +4 -2
  34. package/dist/components/views/PlanComparisonPage.test.js +4 -0
  35. package/dist/components/views/PlansPage.d.ts +7 -1
  36. package/dist/components/views/PlansPage.js +139 -53
  37. package/dist/components/views/PlansPage.test.d.ts +1 -0
  38. package/dist/components/views/PlansPage.test.js +157 -0
  39. package/dist/components/views/StandaloneLayout.d.ts +1 -0
  40. package/dist/components/views/StandaloneLayout.js +216 -186
  41. package/dist/components/views/WidgetView.js +11 -2
  42. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +8 -3
  43. package/dist/components/views/standalone/modals/AccountHubModal.js +16 -2
  44. package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +1 -0
  45. package/dist/components/views/standalone/modals/AccountHubModal.test.js +26 -0
  46. package/dist/components/views/standalone/modals/SyncStatusModal.js +1 -1
  47. package/dist/core/DeletedTaskLifecycleService.d.ts +2 -1
  48. package/dist/core/EntitlementsPolicy.test.js +2 -2
  49. package/dist/core/GlobalSettingsService.js +78 -18
  50. package/dist/core/PlanEntitlementService.d.ts +31 -5
  51. package/dist/core/PlanEntitlementService.js +350 -222
  52. package/dist/core/SignupMonetizationSettings.test.js +208 -49
  53. package/dist/core/SystemAdmin.test.js +186 -96
  54. package/dist/core/TaskActivityService.d.ts +4 -1
  55. package/dist/core/TaskActivityService.js +45 -0
  56. package/dist/core/TaskAuditTimeline.test.js +52 -1
  57. package/dist/core/Taskforce.d.ts +65 -20
  58. package/dist/core/Taskforce.js +261 -51
  59. package/dist/core/Taskforce.mcpAuth.test.js +0 -2
  60. package/dist/core/UserProfileAvatarDrafts.test.js +12 -3
  61. package/dist/core/WorkspacePlanMode.test.js +65 -0
  62. package/dist/core/types.d.ts +11 -12
  63. package/dist/documentReviews/service.d.ts +1 -1
  64. package/dist/documentReviews/types.d.ts +1 -1
  65. package/dist/hooks/auth/useTaskforceAuthBootstrap.d.ts +48 -0
  66. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +219 -0
  67. package/dist/hooks/sync/auth.d.ts +37 -0
  68. package/dist/hooks/sync/auth.js +61 -0
  69. package/dist/hooks/sync/bootstrap.d.ts +34 -0
  70. package/dist/hooks/sync/bootstrap.js +52 -0
  71. package/dist/hooks/sync/orchestratorShared.d.ts +36 -0
  72. package/dist/hooks/sync/orchestratorShared.js +209 -0
  73. package/dist/hooks/sync/orchestratorShared.test.d.ts +1 -0
  74. package/dist/hooks/sync/orchestratorShared.test.js +49 -0
  75. package/dist/hooks/sync/realtime.d.ts +25 -0
  76. package/dist/hooks/sync/realtime.js +60 -0
  77. package/dist/hooks/sync/recovery.d.ts +69 -0
  78. package/dist/hooks/sync/recovery.js +118 -0
  79. package/dist/hooks/sync/transfers.d.ts +122 -0
  80. package/dist/hooks/sync/transfers.js +431 -0
  81. package/dist/hooks/sync/useDataVersionRefresh.d.ts +16 -0
  82. package/dist/hooks/sync/useDataVersionRefresh.js +73 -0
  83. package/dist/hooks/ui/useResourceExport.d.ts +3 -1
  84. package/dist/hooks/ui/useResourceExport.js +5 -2
  85. package/dist/hooks/ui/useWorkflowTemplates.js +11 -6
  86. package/dist/hooks/useRealtimeSync.js +2 -1
  87. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +8 -0
  88. package/dist/hooks/useSyncOrchestrator.context-assets.test.d.ts +1 -0
  89. package/dist/hooks/useSyncOrchestrator.context-assets.test.js +228 -0
  90. package/dist/hooks/useSyncOrchestrator.d.ts +5 -4
  91. package/dist/hooks/useSyncOrchestrator.js +315 -860
  92. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +665 -8
  93. package/dist/hooks/useTaskMutations.js +45 -6
  94. package/dist/hooks/useTaskMutations.test.js +147 -0
  95. package/dist/hooks/useTaskforce.d.ts +11 -2
  96. package/dist/hooks/useTaskforce.js +290 -812
  97. package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
  98. package/dist/hooks/useWorkspaceSyncController.js +22 -10
  99. package/dist/hooks/workspace/useTaskforceApiRouting.test.js +17 -0
  100. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +166 -0
  101. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +434 -0
  102. package/dist/hooks/workspace/workspaceLocalState.d.ts +19 -0
  103. package/dist/hooks/workspace/workspaceLocalState.js +100 -0
  104. package/dist/hooks/workspace/workspaceLocalState.test.d.ts +1 -0
  105. package/dist/hooks/workspace/workspaceLocalState.test.js +59 -0
  106. package/dist/localization/locales/en-US.js +2 -2
  107. package/dist/mcp/canonicalAssetHelpers.d.ts +70 -0
  108. package/dist/mcp/canonicalAssetHelpers.js +259 -0
  109. package/dist/mcp/clientIntegrations.d.ts +12 -1
  110. package/dist/mcp/clientIntegrations.js +161 -6
  111. package/dist/mcp/clientIntegrations.test.js +76 -4
  112. package/dist/mcp/documentHelpers.d.ts +100 -0
  113. package/dist/mcp/documentHelpers.js +161 -0
  114. package/dist/mcp/runtime.js +264 -505
  115. package/dist/mcp/runtime.test.js +353 -89
  116. package/dist/mcp/taskAttachmentHelpers.d.ts +41 -0
  117. package/dist/mcp/taskAttachmentHelpers.js +60 -0
  118. package/dist/mcp/taskAttachmentTypes.d.ts +37 -0
  119. package/dist/mcp/taskAttachmentTypes.js +1 -0
  120. package/dist/migrations/billingSchemaParity.test.js +3 -0
  121. package/dist/migrations/taskSchemaMigrations.js +15 -0
  122. package/dist/plugins/vite.js +1 -1
  123. package/dist/runtime/appGateDefinitions.d.ts +1 -1
  124. package/dist/runtime/appGateDefinitions.js +1 -1
  125. package/dist/server/index.cookieProxy.test.js +1 -0
  126. package/dist/server/index.d.ts +1 -0
  127. package/dist/server/index.js +48 -3
  128. package/dist/server/index.test.js +14 -2
  129. package/dist/server/mockStripe.d.ts +1 -0
  130. package/dist/server/mockStripe.js +2 -0
  131. package/dist/server/routes/admin.js +91 -40
  132. package/dist/server/routes/auth.d.ts +1 -0
  133. package/dist/server/routes/auth.js +231 -55
  134. package/dist/server/routes/billing.js +473 -39
  135. package/dist/server/routes/billing.test.js +972 -37
  136. package/dist/server/routes/shared.d.ts +30 -0
  137. package/dist/server/routes/shared.js +31 -3
  138. package/dist/server/routes/sync.integration.test.js +180 -0
  139. package/dist/server/routes/sync.js +125 -242
  140. package/dist/server/routes/tasks.js +49 -7
  141. package/dist/server/routes.js +11 -22
  142. package/dist/server/routes.test.js +530 -81
  143. package/dist/shared/taskActor.d.ts +10 -0
  144. package/dist/shared/taskActor.js +1 -0
  145. package/dist/sync/cloudSyncApi.d.ts +1 -0
  146. package/dist/sync/collaborationSyncPayload.js +4 -0
  147. package/dist/sync/collaborationSyncPayload.test.js +8 -0
  148. package/dist/sync/collaborationSyncState.d.ts +3 -1
  149. package/dist/sync/collaborationSyncState.js +19 -0
  150. package/dist/sync/contentSyncState.d.ts +10 -0
  151. package/dist/sync/contentSyncState.js +277 -14
  152. package/dist/sync/syncApplyHandlers.d.ts +87 -1
  153. package/dist/sync/syncApplyHandlers.js +370 -8
  154. package/dist/sync/syncApplyHandlers.test.d.ts +1 -0
  155. package/dist/sync/syncApplyHandlers.test.js +377 -0
  156. package/dist/sync/syncService.d.ts +9 -1
  157. package/dist/sync/syncService.js +57 -10
  158. package/dist/sync/syncService.test.js +48 -0
  159. package/dist/sync/workspacePullFeed.d.ts +11 -0
  160. package/dist/sync/workspacePullFeed.js +123 -68
  161. package/dist/sync/workspacePullFeed.test.js +180 -0
  162. package/dist/sync/workspaceSyncModel.d.ts +22 -1
  163. package/dist/sync/workspaceSyncModel.js +65 -0
  164. package/dist/sync/workspaceSyncModel.test.js +119 -0
  165. package/dist/types.d.ts +2 -10
  166. package/dist/ui/assets/AgentsModule-BLWVbuKP.js +1 -0
  167. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-Dvi02Y7q.js → AnnotatedAttachmentWorkspace-BlS-cqnl.js} +1 -1
  168. package/dist/ui/assets/{DocumentWorkspace-iys3zfca.js → DocumentWorkspace-BH_6DF6x.js} +1 -1
  169. package/dist/ui/assets/{InitiativesModule-DJvLJI3A.js → InitiativesModule-BjR6iBf9.js} +1 -1
  170. package/dist/ui/assets/{PlansPage-BlVC_lRq.css → PlansPage-C2dd2n1X.css} +1 -1
  171. package/dist/ui/assets/PlansPage-CSDQ4sLa.js +1 -0
  172. package/dist/ui/assets/TaskSettings-CEDy34Jo.js +9 -0
  173. package/dist/ui/assets/{WorkflowsModule-BguHeQaV.js → WorkflowsModule-DnFqdUME.js} +1 -1
  174. package/dist/ui/assets/index-DXUdtH1B.js +6 -0
  175. package/dist/ui/assets/index-Ii0B0rTO.css +1 -0
  176. package/dist/ui/assets/{vendor-icons-C8EED3Oh.js → vendor-icons-I7ZwG1z_.js} +1 -1
  177. package/dist/ui/index.html +4 -4
  178. package/dist/utils/accountProfileSummaryCache.d.ts +31 -0
  179. package/dist/utils/accountProfileSummaryCache.js +97 -0
  180. package/dist/utils/accountProfileSummaryCache.test.d.ts +1 -0
  181. package/dist/utils/accountProfileSummaryCache.test.js +63 -0
  182. package/dist/utils/taskActivity.js +4 -0
  183. package/dist/utils/taskActivity.test.js +25 -1
  184. package/package.json +5 -3
  185. package/scripts/playwright-auth.sh +4 -0
  186. package/dist/ui/assets/AgentsModule-JG5jc3he.js +0 -1
  187. package/dist/ui/assets/PlansPage-BMPlpFpw.js +0 -1
  188. package/dist/ui/assets/TaskSettings-Bqd4cCzW.js +0 -8
  189. package/dist/ui/assets/index-MXeWoebC.css +0 -1
  190. package/dist/ui/assets/index-YJ4k5QNa.js +0 -6
  191. package/dist/utils/billingStatusCache.d.ts +0 -21
  192. package/dist/utils/billingStatusCache.js +0 -70
@@ -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,28 +486,264 @@ 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
  }
484
- function upsertCanonicalEntitlement(core, input) {
496
+ function updateCheckoutAttemptStatus(core, sessionIdRaw, status) {
497
+ const sessionId = String(sessionIdRaw || '').trim();
498
+ if (!sessionId)
499
+ return;
485
500
  const db = core.getDatabaseAdapter();
486
501
  const tenantId = core.getTenantId();
487
- const now = new Date().toISOString();
488
502
  db.prepare(`
489
- INSERT INTO account_entitlements (account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at)
490
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
491
- ON CONFLICT (account_id) DO UPDATE SET
492
- plan_version_id = excluded.plan_version_id,
493
- entitlement_state = excluded.entitlement_state,
494
- effective_at = excluded.effective_at,
495
- effective_until = excluded.effective_until,
496
- updated_by = excluded.updated_by,
497
- 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);
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 updatedBy = input.updatedBy || 'checkout-start';
737
+ const source = input.source || 'checkout-start';
738
+ core.transitionCanonicalCommercialState({
739
+ accountId: input.userId,
740
+ planVersionId: input.planVersionId,
741
+ state: 'checkout_pending',
742
+ effectiveUntil: input.effectiveUntil || null,
743
+ billingMirrorPlanId: resolvePlanIdForVersion(core, input.planVersionId),
744
+ source,
745
+ updatedBy
746
+ });
499
747
  }
500
748
  function upsertBillingMirror(core, input) {
501
749
  const db = core.getDatabaseAdapter();
@@ -546,7 +794,12 @@ function applySubscriptionSnapshot(core, input) {
546
794
  const canceledState = stripeStatus === 'canceled'
547
795
  ? (currentPeriodEnd && currentPeriodEnd > nowIso ? 'active' : 'canceled')
548
796
  : null;
549
- const canonicalState = canceledState || mapStripeStatusToCanonicalState(stripeStatus);
797
+ const mappedCanonicalState = canceledState || mapStripeStatusToCanonicalState(stripeStatus);
798
+ const preserveExistingAccess = mappedCanonicalState === 'checkout_pending'
799
+ && isCommercialStateAppAccessible(existingEntitlement?.entitlement_state);
800
+ const canonicalState = preserveExistingAccess
801
+ ? String(existingEntitlement?.entitlement_state || 'active').trim().toLowerCase()
802
+ : mappedCanonicalState;
550
803
  const effectiveUntil = stripeStatus === 'canceled'
551
804
  ? currentPeriodEnd
552
805
  : (currentPeriodEnd || null);
@@ -563,13 +816,25 @@ function applySubscriptionSnapshot(core, input) {
563
816
  currentPeriodEnd,
564
817
  effectiveUntil
565
818
  });
566
- if (planVersionId) {
567
- upsertCanonicalEntitlement(core, {
568
- userId,
819
+ if (planVersionId && canonicalState === 'checkout_pending') {
820
+ if (shouldRecordCheckoutPendingState(existingEntitlement)) {
821
+ beginCheckoutPending(core, {
822
+ userId,
823
+ planVersionId,
824
+ source,
825
+ updatedBy: source
826
+ });
827
+ }
828
+ }
829
+ else if (planVersionId) {
830
+ core.transitionCanonicalCommercialState({
831
+ accountId: userId,
569
832
  planVersionId: planVersionId || existingEntitlement?.plan_version_id || core.getDefaultPlanVersionIdByMode('personal', { allowFallback: true }),
570
833
  state: canonicalState,
571
834
  effectiveAt: trialEnd || nowIso,
572
835
  effectiveUntil,
836
+ billingMirrorPlanId: resolvePlanIdForVersion(core, planVersionId),
837
+ source,
573
838
  updatedBy: source
574
839
  });
575
840
  }
@@ -934,12 +1199,23 @@ export function registerBillingRoutes(deps) {
934
1199
  return;
935
1200
  }
936
1201
  try {
1202
+ maintainCheckoutAttempts(core);
937
1203
  const userId = requestUserId(req);
938
1204
  const db = core.getDatabaseAdapter();
939
1205
  const tenantId = core.getTenantId();
940
- const billingReconciliation = await reconcileBillingMirrorIfNeeded(core, userId);
941
- const row = billingReconciliation.row;
1206
+ const row = getBillingMirrorRow(core, userId);
942
1207
  const access = core.getAccountAccessStatus(userId);
1208
+ const billingConflictCode = resolveBillingConflictCode(core, {
1209
+ userId,
1210
+ gate: String(access.gate || '')
1211
+ });
1212
+ const resolvedPlanId = String(access.entitlement?.planId || row?.tier || '').trim() || null;
1213
+ const planRow = resolvedPlanId ? db.prepare(`
1214
+ SELECT display_name
1215
+ FROM plan_catalog
1216
+ WHERE tenant_id = ? AND plan_id = ?
1217
+ LIMIT 1
1218
+ `).get(tenantId, resolvedPlanId) : undefined;
943
1219
  if (!access.hasEntitlement || !access.entitlement) {
944
1220
  res.writeHead(200, { 'Content-Type': 'application/json' });
945
1221
  res.end(JSON.stringify({
@@ -951,24 +1227,18 @@ export function registerBillingRoutes(deps) {
951
1227
  trialEnd: row?.trial_end || null,
952
1228
  currentPeriodEnd: row?.current_period_end || null,
953
1229
  effectiveUntil: row?.effective_until || null,
954
- entitlementState: null,
1230
+ entitlementState: row?.entitlement_state || null,
955
1231
  planVersionId: null,
956
- planId: null,
957
- planName: null,
1232
+ planId: resolvedPlanId,
1233
+ planName: planRow?.display_name ? String(planRow.display_name) : resolvedPlanId,
958
1234
  gate: access.gate,
959
1235
  message: access.message,
960
1236
  planSelectionRequired: access.planSelectionRequired,
961
- billingConflictCode: billingReconciliation.conflictCode
1237
+ billingConflictCode
962
1238
  }));
963
1239
  return;
964
1240
  }
965
1241
  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
1242
  res.writeHead(200, { 'Content-Type': 'application/json' });
973
1243
  res.end(JSON.stringify({
974
1244
  stripeStatus: row?.stripe_status || null,
@@ -986,7 +1256,7 @@ export function registerBillingRoutes(deps) {
986
1256
  gate: access.gate,
987
1257
  message: access.message,
988
1258
  planSelectionRequired: access.planSelectionRequired,
989
- billingConflictCode: billingReconciliation.conflictCode
1259
+ billingConflictCode
990
1260
  }));
991
1261
  }
992
1262
  catch (error) {
@@ -1290,6 +1560,68 @@ export function registerBillingRoutes(deps) {
1290
1560
  rows
1291
1561
  }));
1292
1562
  });
1563
+ addRoute('GET', '/api/taskforce/admin/billing/checkout-attempts', async (req, res) => {
1564
+ if (!ensureCloudBilling(req, res))
1565
+ return;
1566
+ if (!ensureAdminRole(req)) {
1567
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1568
+ res.end(JSON.stringify({ error: 'Forbidden: system admin role required.' }));
1569
+ return;
1570
+ }
1571
+ const db = core.getDatabaseAdapter();
1572
+ const tenantId = core.getTenantId();
1573
+ const requestUrl = parseRequestUrl(req);
1574
+ const limit = parsePositiveIntBounded(requestUrl.searchParams.get('limit'), 50, 1, 200);
1575
+ const filterUserId = String(requestUrl.searchParams.get('userId') || '').trim();
1576
+ const now = new Date().toISOString();
1577
+ const maintenance = maintainCheckoutAttempts(core);
1578
+ const rows = filterUserId
1579
+ ? db.prepare(`
1580
+ SELECT session_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1581
+ FROM billing_checkout_attempts
1582
+ WHERE tenant_id = ? AND user_id = ?
1583
+ ORDER BY created_at DESC, session_id DESC
1584
+ LIMIT ?
1585
+ `).all(tenantId, filterUserId, limit)
1586
+ : db.prepare(`
1587
+ SELECT session_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1588
+ FROM billing_checkout_attempts
1589
+ WHERE tenant_id = ?
1590
+ ORDER BY created_at DESC, session_id DESC
1591
+ LIMIT ?
1592
+ `).all(tenantId, limit);
1593
+ const total = filterUserId
1594
+ ? db.prepare(`
1595
+ SELECT COUNT(*) AS count
1596
+ FROM billing_checkout_attempts
1597
+ WHERE tenant_id = ? AND user_id = ?
1598
+ `).get(tenantId, filterUserId)
1599
+ : db.prepare(`
1600
+ SELECT COUNT(*) AS count
1601
+ FROM billing_checkout_attempts
1602
+ WHERE tenant_id = ?
1603
+ `).get(tenantId);
1604
+ const openCount = filterUserId
1605
+ ? db.prepare(`
1606
+ SELECT COUNT(*) AS count
1607
+ FROM billing_checkout_attempts
1608
+ WHERE tenant_id = ? AND user_id = ? AND status = 'open'
1609
+ `).get(tenantId, filterUserId)
1610
+ : db.prepare(`
1611
+ SELECT COUNT(*) AS count
1612
+ FROM billing_checkout_attempts
1613
+ WHERE tenant_id = ? AND status = 'open'
1614
+ `).get(tenantId);
1615
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1616
+ res.end(JSON.stringify({
1617
+ asOf: now,
1618
+ expiredRowsNormalized: maintenance.expiredRows,
1619
+ purgedTerminalRows: maintenance.purgedRows,
1620
+ total: Number(total?.count || 0),
1621
+ openCount: Number(openCount?.count || 0),
1622
+ rows
1623
+ }));
1624
+ });
1293
1625
  addRoute('POST', '/api/taskforce/admin/billing/prices/import', async (req, res) => {
1294
1626
  if (!ensureCloudBilling(req, res))
1295
1627
  return;
@@ -1388,6 +1720,7 @@ export function registerBillingRoutes(deps) {
1388
1720
  return;
1389
1721
  }
1390
1722
  try {
1723
+ maintainCheckoutAttempts(core);
1391
1724
  const userId = requestUserId(req);
1392
1725
  const currentBillingResult = await reconcileBillingMirrorIfNeeded(core, userId);
1393
1726
  const currentBilling = currentBillingResult.row;
@@ -1551,6 +1884,21 @@ export function registerBillingRoutes(deps) {
1551
1884
  billingInterval: interval
1552
1885
  }
1553
1886
  });
1887
+ const checkoutEffectiveUntil = isoFromUnixSeconds(session.expires_at);
1888
+ recordCheckoutAttempt(core, {
1889
+ sessionId: session.id,
1890
+ userId,
1891
+ planVersionId,
1892
+ billingInterval: interval,
1893
+ expiresAt: checkoutEffectiveUntil
1894
+ });
1895
+ if (shouldRecordCheckoutPendingState(currentEntitlement) && !isCommercialStateAppAccessible(currentEntitlement?.entitlement_state)) {
1896
+ beginCheckoutPending(core, {
1897
+ userId,
1898
+ planVersionId,
1899
+ effectiveUntil: checkoutEffectiveUntil
1900
+ });
1901
+ }
1554
1902
  const checkoutUrl = isMockStripeClient(stripe)
1555
1903
  ? buildMockStripeHostedCheckoutUrl(resolveHostedServerBaseUrl(req), session.id)
1556
1904
  : (session.url || null);
@@ -1592,11 +1940,30 @@ export function registerBillingRoutes(deps) {
1592
1940
  return;
1593
1941
  }
1594
1942
  try {
1943
+ maintainCheckoutAttempts(core);
1595
1944
  const stripe = getStripeClient();
1596
1945
  const expectedUserId = requestUserId(req);
1597
1946
  const session = await stripe.checkout.sessions.retrieve(sessionId, {
1598
1947
  expand: ['subscription']
1599
1948
  });
1949
+ const sessionStatus = String(session.status || '').trim().toLowerCase();
1950
+ const paymentStatus = String(session.payment_status || '').trim().toLowerCase();
1951
+ if (session.mode !== 'subscription') {
1952
+ res.writeHead(409, { 'Content-Type': 'application/json' });
1953
+ res.end(JSON.stringify({
1954
+ error: 'Checkout session is not a subscription checkout.',
1955
+ code: 'CHECKOUT_SESSION_INVALID_MODE'
1956
+ }));
1957
+ return;
1958
+ }
1959
+ if (sessionStatus !== 'complete' || (paymentStatus !== 'paid' && paymentStatus !== 'no_payment_required')) {
1960
+ res.writeHead(409, { 'Content-Type': 'application/json' });
1961
+ res.end(JSON.stringify({
1962
+ error: 'Checkout session has not completed yet.',
1963
+ code: 'CHECKOUT_SESSION_INCOMPLETE'
1964
+ }));
1965
+ return;
1966
+ }
1600
1967
  const metadata = session.metadata || {};
1601
1968
  const customerId = String(session.customer || '').trim();
1602
1969
  const subscriptionId = typeof session.subscription === 'string'
@@ -1615,22 +1982,47 @@ export function registerBillingRoutes(deps) {
1615
1982
  res.end(JSON.stringify({ error: 'Checkout session does not belong to the current account.' }));
1616
1983
  return;
1617
1984
  }
1985
+ let resolvedPlanVersionId = null;
1986
+ let subscription = null;
1987
+ if (subscriptionId) {
1988
+ subscription = typeof session.subscription === 'string' || !session.subscription
1989
+ ? await stripe.subscriptions.retrieve(subscriptionId)
1990
+ : session.subscription;
1991
+ const priceId = String(subscription.items?.data?.[0]?.price?.id || '').trim();
1992
+ resolvedPlanVersionId = resolvePlanVersionIdForBillingEvent(core, [
1993
+ subscription.metadata?.planVersionId,
1994
+ subscription.metadata?.plan_version_id,
1995
+ metadata.planVersionId,
1996
+ metadata.plan_version_id
1997
+ ], priceId).planVersionId;
1998
+ const lifecycleGuard = resolveCheckoutLifecycleGuard(core, {
1999
+ userId: resolvedUserId,
2000
+ planVersionId: resolvedPlanVersionId,
2001
+ sessionId,
2002
+ subscriptionId
2003
+ });
2004
+ if (lifecycleGuard.blocked) {
2005
+ res.writeHead(409, { 'Content-Type': 'application/json' });
2006
+ res.end(JSON.stringify({
2007
+ error: lifecycleGuard.message,
2008
+ code: lifecycleGuard.code
2009
+ }));
2010
+ return;
2011
+ }
2012
+ }
1618
2013
  core.createUserBillingRecord(resolvedUserId);
1619
2014
  upsertBillingMirror(core, {
1620
2015
  userId: resolvedUserId,
1621
2016
  stripeCustomerId: customerId || null,
1622
2017
  stripeSubscriptionId: subscriptionId || null
1623
2018
  });
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;
2019
+ if (subscription) {
1629
2020
  resolvedPlanVersionId = applySubscriptionSnapshot(core, {
1630
2021
  userId: resolvedUserId,
1631
2022
  subscription,
1632
2023
  source: 'checkout-confirm'
1633
2024
  }).planVersionId;
2025
+ updateCheckoutAttemptStatus(core, sessionId, 'confirmed');
1634
2026
  }
1635
2027
  res.writeHead(200, { 'Content-Type': 'application/json' });
1636
2028
  res.end(JSON.stringify({
@@ -1801,6 +2193,7 @@ export function registerBillingRoutes(deps) {
1801
2193
  const db = core.getDatabaseAdapter();
1802
2194
  const tenantId = core.getTenantId();
1803
2195
  purgeExpiredWebhookFailures(core);
2196
+ maintainCheckoutAttempts(core);
1804
2197
  let duplicate = false;
1805
2198
  const tx = db.transaction(() => {
1806
2199
  const acquired = lockWebhookEvent(core, event);
@@ -1853,11 +2246,31 @@ export function registerBillingRoutes(deps) {
1853
2246
  });
1854
2247
  return;
1855
2248
  }
1856
- applySubscriptionSnapshot(core, {
2249
+ const lifecycleGuard = resolveCheckoutLifecycleGuard(core, {
2250
+ userId,
2251
+ planVersionId,
2252
+ subscriptionId: String(subscription.id || '').trim()
2253
+ });
2254
+ if (lifecycleGuard.blocked) {
2255
+ recordWebhookFailure(core, {
2256
+ eventId: event.id,
2257
+ eventType: event.type,
2258
+ reason: lifecycleGuard.message,
2259
+ payload: event
2260
+ });
2261
+ return;
2262
+ }
2263
+ const snapshot = applySubscriptionSnapshot(core, {
1857
2264
  userId,
1858
2265
  subscription,
1859
2266
  source: 'stripe-webhook'
1860
2267
  });
2268
+ if (snapshot.canonicalState !== 'checkout_pending') {
2269
+ markLatestOpenCheckoutAttemptConfirmed(core, {
2270
+ userId,
2271
+ planVersionId: snapshot.planVersionId
2272
+ });
2273
+ }
1861
2274
  return;
1862
2275
  }
1863
2276
  if (event.type === 'invoice.paid' || event.type === 'invoice.payment_failed') {
@@ -1878,6 +2291,20 @@ export function registerBillingRoutes(deps) {
1878
2291
  invoice.subscription_details?.metadata?.plan_version_id
1879
2292
  ], priceId);
1880
2293
  const planVersionId = planVersionResolution.planVersionId;
2294
+ const lifecycleGuard = resolveCheckoutLifecycleGuard(core, {
2295
+ userId,
2296
+ planVersionId,
2297
+ subscriptionId
2298
+ });
2299
+ if (lifecycleGuard.blocked) {
2300
+ recordWebhookFailure(core, {
2301
+ eventId: event.id,
2302
+ eventType: event.type,
2303
+ reason: lifecycleGuard.message,
2304
+ payload: event
2305
+ });
2306
+ return;
2307
+ }
1881
2308
  const existingEntitlement = getEntitlementRow(core, userId);
1882
2309
  const canonicalState = event.type === 'invoice.payment_failed' ? 'grace' : 'active';
1883
2310
  const currentPeriodEnd = invoice.lines?.data?.[0]?.period?.end
@@ -1906,12 +2333,19 @@ export function registerBillingRoutes(deps) {
1906
2333
  });
1907
2334
  return;
1908
2335
  }
1909
- upsertCanonicalEntitlement(core, {
1910
- userId,
2336
+ core.transitionCanonicalCommercialState({
2337
+ accountId: userId,
1911
2338
  planVersionId: planVersionId || existingEntitlement?.plan_version_id || core.getDefaultPlanVersionIdByMode('personal', { allowFallback: true }),
1912
2339
  state: canonicalState,
1913
2340
  effectiveAt: new Date().toISOString(),
1914
- effectiveUntil: currentPeriodEnd
2341
+ effectiveUntil: currentPeriodEnd,
2342
+ billingMirrorPlanId: resolvePlanIdForVersion(core, planVersionId),
2343
+ source: 'stripe-webhook',
2344
+ updatedBy: 'stripe-webhook'
2345
+ });
2346
+ markLatestOpenCheckoutAttemptConfirmed(core, {
2347
+ userId,
2348
+ planVersionId
1915
2349
  });
1916
2350
  return;
1917
2351
  }