@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
@@ -35,7 +35,13 @@ export class PlanEntitlementService {
35
35
  // ─── Normalizers ─────────────────────────────────────────────────────────
36
36
  normalizeEntitlementState(value) {
37
37
  const normalized = String(value || '').trim().toLowerCase();
38
- if (normalized === 'active' || normalized === 'trialing' || normalized === 'grace' || normalized === 'suspended' || normalized === 'canceled') {
38
+ if (normalized === 'pending_plan_selection'
39
+ || normalized === 'checkout_pending'
40
+ || normalized === 'active'
41
+ || normalized === 'trialing'
42
+ || normalized === 'grace'
43
+ || normalized === 'suspended'
44
+ || normalized === 'canceled') {
39
45
  return normalized;
40
46
  }
41
47
  return 'canceled';
@@ -112,6 +118,76 @@ export class PlanEntitlementService {
112
118
  isEntitlementStateAllowed(state) {
113
119
  return state === 'active' || state === 'trialing' || state === 'grace';
114
120
  }
121
+ isIsoTimestampExpired(value) {
122
+ const timestamp = String(value || '').trim();
123
+ if (!timestamp)
124
+ return false;
125
+ const expiresAt = Date.parse(timestamp);
126
+ if (!Number.isFinite(expiresAt))
127
+ return false;
128
+ return expiresAt <= Date.now();
129
+ }
130
+ readAccountEntitlementRow(accountId) {
131
+ return this.db.prepare(`
132
+ SELECT account_id, plan_version_id, entitlement_state, effective_at, effective_until
133
+ FROM account_entitlements
134
+ WHERE tenant_id = ? AND account_id = ?
135
+ LIMIT 1
136
+ `).get(this.tenantId, accountId);
137
+ }
138
+ normalizeExpiredCheckoutPendingRow(accountId) {
139
+ const row = this.readAccountEntitlementRow(accountId);
140
+ if (!row?.account_id || !row.plan_version_id) {
141
+ return row;
142
+ }
143
+ const currentState = this.normalizeEntitlementState(row.entitlement_state);
144
+ if (currentState !== 'checkout_pending' || !this.isIsoTimestampExpired(row.effective_until || null)) {
145
+ return row;
146
+ }
147
+ const now = new Date().toISOString();
148
+ this.db.prepare(`
149
+ UPDATE account_entitlements
150
+ SET entitlement_state = ?, effective_until = NULL, updated_by = ?, updated_at = ?
151
+ WHERE tenant_id = ? AND account_id = ?
152
+ `).run('pending_plan_selection', 'checkout-expired', now, this.tenantId, accountId);
153
+ this.createUserBillingRecord(accountId, {
154
+ entitlementState: 'pending_plan_selection',
155
+ planId: null
156
+ });
157
+ return this.readAccountEntitlementRow(accountId);
158
+ }
159
+ isBillingConfirmedTransitionSource(source) {
160
+ return source === 'checkout-confirm' || source === 'stripe-webhook';
161
+ }
162
+ resolveCanonicalTransitionSource(updatedBy) {
163
+ if (updatedBy === 'registration'
164
+ || updatedBy === 'checkout-start'
165
+ || updatedBy === 'checkout-confirm'
166
+ || updatedBy === 'stripe-webhook'
167
+ || updatedBy === 'backfill'
168
+ || updatedBy === 'legacy-plan-mode') {
169
+ return updatedBy;
170
+ }
171
+ return 'system';
172
+ }
173
+ resolvePendingPlanVersionId(planVersionIdRaw) {
174
+ const planVersionId = String(planVersionIdRaw || '').trim();
175
+ if (planVersionId) {
176
+ return this.getPlanVersionSnapshot(planVersionId).planVersionId;
177
+ }
178
+ return this.getDefaultPlanVersionByMode('personal', { allowFallback: true }).planVersionId;
179
+ }
180
+ buildBlockedAccessStatus(input) {
181
+ return {
182
+ gate: input.gate,
183
+ hasEntitlement: Boolean(input.entitlement),
184
+ canAccessApp: false,
185
+ canAccessPlans: true,
186
+ planSelectionRequired: input.planSelectionRequired === true,
187
+ message: input.message,
188
+ entitlement: input.entitlement
189
+ };
190
+ }
115
191
  // ─── Plan defaults repair ─────────────────────────────────────────────────
116
192
  repairPlanDefaultVersionIfNeeded(planIdRaw) {
117
193
  const planId = String(planIdRaw || '').trim().toLowerCase();
@@ -152,42 +228,6 @@ export class PlanEntitlementService {
152
228
  this.repairPlanDefaultVersionIfNeeded(planId);
153
229
  }
154
230
  }
155
- repairSignupMonetizationSettingsIfNeeded() {
156
- const current = this.deps.readGlobalConfig();
157
- const currentAuth = current.auth && typeof current.auth === 'object'
158
- ? current.auth
159
- : {};
160
- const strategy = currentAuth.signupMonetizationStrategy === 'plan_selection_required'
161
- ? 'plan_selection_required'
162
- : currentAuth.signupMonetizationStrategy === 'auto_assign_default'
163
- ? 'auto_assign_default'
164
- : null;
165
- const hasStrategy = strategy === 'auto_assign_default' || strategy === 'plan_selection_required';
166
- const hasDefaultVersionId = typeof currentAuth.defaultSignupPlanVersionId === 'string'
167
- && currentAuth.defaultSignupPlanVersionId.trim().length > 0;
168
- if (strategy === 'plan_selection_required') {
169
- return;
170
- }
171
- if (strategy === 'auto_assign_default' && hasDefaultVersionId) {
172
- return;
173
- }
174
- const fallbackVersion = this.tryGetDefaultPlanVersionByMode('personal', { allowFallback: true });
175
- if (!fallbackVersion) {
176
- return;
177
- }
178
- this.deps.writeGlobalConfig({
179
- ...current,
180
- auth: {
181
- ...currentAuth,
182
- signupMonetizationStrategy: hasStrategy
183
- ? strategy
184
- : 'auto_assign_default',
185
- defaultSignupPlanVersionId: strategy === 'auto_assign_default' && hasDefaultVersionId
186
- ? currentAuth.defaultSignupPlanVersionId
187
- : fallbackVersion.planVersionId
188
- }
189
- });
190
- }
191
231
  normalizeLegacyPlanDataIfNeeded() {
192
232
  this.migrateLegacySeatLimitFeatureConfigToVersionSeatLimit();
193
233
  this.normalizeLegacyBillingMirrorRows();
@@ -301,16 +341,16 @@ export class PlanEntitlementService {
301
341
  if (!accountId)
302
342
  continue;
303
343
  const rawBillingState = String(row.entitlement_state || '').trim().toLowerCase();
304
- if (rawBillingState === 'pending_plan_selection')
305
- continue;
306
344
  const mirroredPlanId = String(row.tier || '').trim().toLowerCase();
307
345
  const planVersion = this.tryGetDefaultPlanVersionByPlanId(mirroredPlanId)
308
346
  || defaultPersonalVersion;
309
- this.upsertCanonicalEntitlementRecord({
347
+ this.transitionCanonicalCommercialState({
310
348
  accountId,
311
349
  planVersionId: planVersion.planVersionId,
312
350
  state: this.resolveCanonicalEntitlementStateFromBilling(row.entitlement_state),
313
351
  effectiveAt: row.billing_created_at ? String(row.billing_created_at) : undefined,
352
+ billingMirrorPlanId: rawBillingState === 'pending_plan_selection' ? null : planVersion.planId,
353
+ source: 'backfill',
314
354
  updatedBy: 'backfill',
315
355
  createdAt: row.billing_created_at ? String(row.billing_created_at) : undefined,
316
356
  updatedAt: row.billing_updated_at ? String(row.billing_updated_at) : undefined
@@ -351,7 +391,39 @@ export class PlanEntitlementService {
351
391
  resolveCanonicalEntitlementStateFromBilling(raw) {
352
392
  return this.normalizeEntitlementState(raw);
353
393
  }
354
- upsertCanonicalEntitlementRecord(input) {
394
+ transitionCanonicalCommercialState(input) {
395
+ const accountId = String(input.accountId || '').trim();
396
+ if (!accountId) {
397
+ throw new TaskforceRuleError('accountId is required', 400, 'ACCOUNT_ID_REQUIRED');
398
+ }
399
+ const nextState = this.normalizeEntitlementState(input.state);
400
+ const existing = this.tryGetAccountEntitlement(accountId);
401
+ const planVersionId = nextState === 'pending_plan_selection'
402
+ ? this.resolvePendingPlanVersionId(input.planVersionId || existing?.planVersionId || null)
403
+ : String(input.planVersionId || existing?.planVersionId || '').trim();
404
+ if (!planVersionId) {
405
+ throw new TaskforceRuleError('planVersionId is required', 400, 'PLAN_VERSION_ID_REQUIRED');
406
+ }
407
+ const planVersion = this.getPlanVersionSnapshot(planVersionId);
408
+ if (existing && !this.isValidEntitlementTransition(existing.state, nextState)) {
409
+ throw new TaskforceRuleError('Invalid entitlement state transition', 409, 'INVALID_STATE_TRANSITION');
410
+ }
411
+ const planHasActivePricing = this.planHasAnyActivePricing(planVersion.planId);
412
+ const planVersionHasActiveFreePricing = this.planVersionHasActiveFreePricing(planVersion.planVersionId);
413
+ if ((nextState === 'active' || nextState === 'grace')
414
+ && planHasActivePricing
415
+ && !planVersionHasActiveFreePricing
416
+ && !this.isBillingConfirmedTransitionSource(input.source)
417
+ && input.source !== 'admin'
418
+ && input.source !== 'backfill'
419
+ && input.source !== 'legacy-plan-mode') {
420
+ throw new TaskforceRuleError('Paid active access can only be activated from billing-confirmed transitions.', 409, 'PAID_ACCESS_REQUIRES_BILLING_CONFIRMATION', {
421
+ accountId,
422
+ planVersionId,
423
+ state: nextState,
424
+ source: input.source
425
+ });
426
+ }
355
427
  const now = new Date().toISOString();
356
428
  const createdAt = input.createdAt || now;
357
429
  const updatedAt = input.updatedAt || now;
@@ -365,7 +437,20 @@ export class PlanEntitlementService {
365
437
  effective_until = excluded.effective_until,
366
438
  updated_by = excluded.updated_by,
367
439
  updated_at = excluded.updated_at
368
- `).run(input.accountId, this.tenantId, input.planVersionId, input.state, input.effectiveAt || now, input.effectiveUntil || null, input.updatedBy, createdAt, updatedAt);
440
+ `).run(accountId, this.tenantId, planVersion.planVersionId, nextState, input.effectiveAt || now, input.effectiveUntil || null, input.updatedBy, createdAt, updatedAt);
441
+ this.createUserBillingRecord(accountId, {
442
+ entitlementState: nextState,
443
+ planId: input.billingMirrorPlanId !== undefined
444
+ ? input.billingMirrorPlanId
445
+ : (nextState === 'pending_plan_selection' ? null : planVersion.planId)
446
+ });
447
+ return this.getAccountEntitlement(accountId);
448
+ }
449
+ upsertCanonicalEntitlementRecord(input) {
450
+ this.transitionCanonicalCommercialState({
451
+ ...input,
452
+ source: this.resolveCanonicalTransitionSource(input.updatedBy)
453
+ });
369
454
  }
370
455
  isFeatureEnabledForPlanMode(feature) {
371
456
  return Boolean(feature && feature.access !== 'disabled');
@@ -463,6 +548,26 @@ export class PlanEntitlementService {
463
548
  `).get(this.tenantId, planId);
464
549
  return Number(row?.count || 0) > 0;
465
550
  }
551
+ planVersionHasActiveFreePricing(planVersionIdRaw) {
552
+ const planVersionId = String(planVersionIdRaw || '').trim();
553
+ if (!planVersionId)
554
+ return false;
555
+ const row = this.db.prepare(`
556
+ SELECT COUNT(*) AS count
557
+ FROM billing_price_mappings
558
+ WHERE tenant_id = ?
559
+ AND plan_version_id = ?
560
+ AND active = 1
561
+ AND pricing_type = 'free'
562
+ `).get(this.tenantId, planVersionId);
563
+ return Number(row?.count || 0) > 0;
564
+ }
565
+ planVersionIsTrialEligible(planVersionIdRaw) {
566
+ const planVersionId = String(planVersionIdRaw || '').trim();
567
+ if (!planVersionId)
568
+ return false;
569
+ return this.getPlanVersionSnapshot(planVersionId).trialDays > 0;
570
+ }
466
571
  tryGetDefaultPlanVersionByPlanId(planIdRaw) {
467
572
  const planId = String(planIdRaw || '').trim().toLowerCase();
468
573
  if (!planId)
@@ -522,51 +627,129 @@ export class PlanEntitlementService {
522
627
  throw error;
523
628
  }
524
629
  }
525
- resolveRegistrationPlanAssignment(selectedPlanVersionIdRaw) {
630
+ getOnboardingPolicy() {
631
+ const billing = this.deps.readGlobalConfig().billing || {};
632
+ const policy = billing.onboardingPolicy && typeof billing.onboardingPolicy === 'object'
633
+ ? billing.onboardingPolicy
634
+ : null;
635
+ return {
636
+ mode: policy?.mode === 'auto_grant_free' || policy?.mode === 'auto_start_trial'
637
+ ? policy.mode
638
+ : 'require_plan_selection',
639
+ planVersionId: typeof policy?.planVersionId === 'string'
640
+ ? (policy.planVersionId.trim() || null)
641
+ : null
642
+ };
643
+ }
644
+ validateOnboardingPolicy(input) {
645
+ const current = this.getOnboardingPolicy();
646
+ const mode = input?.mode === 'auto_grant_free' || input?.mode === 'auto_start_trial'
647
+ ? input.mode
648
+ : input?.mode === 'require_plan_selection'
649
+ ? 'require_plan_selection'
650
+ : current.mode;
651
+ const planVersionId = input?.planVersionId !== undefined
652
+ ? (typeof input.planVersionId === 'string' ? input.planVersionId.trim() || null : null)
653
+ : current.planVersionId;
654
+ if (mode === 'require_plan_selection') {
655
+ return { valid: true };
656
+ }
657
+ if (!planVersionId) {
658
+ return {
659
+ valid: false,
660
+ code: 'ONBOARDING_PLAN_VERSION_REQUIRED',
661
+ message: 'An onboarding plan version is required for the selected onboarding mode.'
662
+ };
663
+ }
664
+ const row = this.db.prepare(`
665
+ SELECT v.plan_version_id, v.plan_id, COALESCE(p.status, 'active') AS plan_status
666
+ FROM plan_versions v
667
+ JOIN plan_catalog p ON p.tenant_id = v.tenant_id AND p.plan_id = v.plan_id
668
+ WHERE v.tenant_id = ? AND v.plan_version_id = ?
669
+ LIMIT 1
670
+ `).get(this.tenantId, planVersionId);
671
+ if (!row?.plan_version_id || !row.plan_id) {
672
+ return {
673
+ valid: false,
674
+ code: 'ONBOARDING_PLAN_VERSION_NOT_FOUND',
675
+ message: `Configured onboarding plan version "${planVersionId}" was not found.`,
676
+ details: { planVersionId }
677
+ };
678
+ }
679
+ if (String(row.plan_status || '').trim().toLowerCase() !== 'active') {
680
+ return {
681
+ valid: false,
682
+ code: 'ONBOARDING_PLAN_NOT_ENABLED',
683
+ message: `Configured onboarding plan version "${planVersionId}" belongs to a disabled plan.`,
684
+ details: {
685
+ planVersionId,
686
+ planId: String(row.plan_id)
687
+ }
688
+ };
689
+ }
690
+ if (mode === 'auto_grant_free' && !this.planVersionHasActiveFreePricing(planVersionId)) {
691
+ return {
692
+ valid: false,
693
+ code: 'ONBOARDING_PLAN_MUST_BE_FREE',
694
+ message: `Configured onboarding plan version "${planVersionId}" does not have active free pricing.`,
695
+ details: {
696
+ planVersionId,
697
+ planId: String(row.plan_id)
698
+ }
699
+ };
700
+ }
701
+ if (mode === 'auto_start_trial' && !this.planVersionIsTrialEligible(planVersionId)) {
702
+ return {
703
+ valid: false,
704
+ code: 'ONBOARDING_PLAN_TRIAL_REQUIRED',
705
+ message: `Configured onboarding plan version "${planVersionId}" is not trial eligible.`,
706
+ details: {
707
+ planVersionId,
708
+ planId: String(row.plan_id)
709
+ }
710
+ };
711
+ }
712
+ return { valid: true };
713
+ }
714
+ resolveRegistrationOnboardingOutcome(selectedPlanVersionIdRaw) {
526
715
  const selectedPlanVersionId = String(selectedPlanVersionIdRaw || '').trim();
527
716
  if (selectedPlanVersionId) {
528
- const row = this.db.prepare(`
529
- SELECT v.plan_version_id, v.plan_id, COALESCE(p.status, 'active') AS plan_status
530
- FROM plan_versions v
531
- JOIN plan_catalog p ON p.tenant_id = v.tenant_id AND p.plan_id = v.plan_id
532
- WHERE v.tenant_id = ? AND v.plan_version_id = ?
533
- LIMIT 1
534
- `).get(this.tenantId, selectedPlanVersionId);
535
- if (!row?.plan_version_id || !row.plan_id) {
536
- throw new TaskforceRuleError(`Selected signup plan version "${selectedPlanVersionId}" was not found.`, 400, 'SIGNUP_PLAN_VERSION_NOT_FOUND', { planVersionId: selectedPlanVersionId });
537
- }
538
- if (String(row.plan_status || '').trim().toLowerCase() !== 'active') {
539
- throw new TaskforceRuleError(`Selected signup plan version "${selectedPlanVersionId}" belongs to a disabled plan.`, 400, 'SIGNUP_PLAN_NOT_ENABLED', {
540
- planVersionId: selectedPlanVersionId,
541
- planId: String(row.plan_id)
542
- });
543
- }
717
+ const planVersion = this.getPlanVersionSnapshot(selectedPlanVersionId);
718
+ const commercialState = this.planVersionHasActiveFreePricing(planVersion.planVersionId)
719
+ ? 'active'
720
+ : 'checkout_pending';
544
721
  return {
545
722
  requiresPlanSelection: false,
546
- planVersionId: String(row.plan_version_id),
547
- planId: String(row.plan_id)
723
+ checkoutPending: commercialState === 'checkout_pending',
724
+ commercialState,
725
+ planVersionId: planVersion.planVersionId,
726
+ planId: planVersion.planId
548
727
  };
549
728
  }
550
- const signupSettings = this.getSignupMonetizationSettings();
551
- if (signupSettings.strategy === 'plan_selection_required') {
729
+ const onboardingPolicy = this.getOnboardingPolicy();
730
+ const validation = this.validateOnboardingPolicy(onboardingPolicy);
731
+ if (!validation.valid) {
732
+ throw new TaskforceRuleError(validation.message || 'Onboarding policy is misconfigured.', 500, validation.code || 'ONBOARDING_POLICY_INVALID', validation.details);
733
+ }
734
+ if (onboardingPolicy.mode === 'require_plan_selection') {
552
735
  return {
553
736
  requiresPlanSelection: true,
737
+ checkoutPending: false,
738
+ commercialState: 'pending_plan_selection',
554
739
  planVersionId: null,
555
740
  planId: null
556
741
  };
557
742
  }
558
- const validation = this.validateSignupMonetizationSettings(signupSettings);
559
- if (!validation.valid) {
560
- throw new TaskforceRuleError(validation.message || 'Signup monetization is misconfigured.', 500, validation.code || 'SIGNUP_MONETIZATION_MISCONFIGURED', validation.details);
561
- }
562
- const configuredVersion = signupSettings.strategy === 'auto_assign_default' && signupSettings.defaultSignupPlanVersionId
563
- ? this.getPlanVersionSnapshot(signupSettings.defaultSignupPlanVersionId)
743
+ const configuredVersion = onboardingPolicy.planVersionId
744
+ ? this.getPlanVersionSnapshot(onboardingPolicy.planVersionId)
564
745
  : null;
565
746
  if (!configuredVersion) {
566
- throw new TaskforceRuleError('Default signup plan version is required when signup strategy is auto-assign default.', 500, 'SIGNUP_PLAN_VERSION_REQUIRED');
747
+ throw new TaskforceRuleError('Configured onboarding plan version is required.', 500, 'ONBOARDING_PLAN_VERSION_REQUIRED');
567
748
  }
568
749
  return {
569
750
  requiresPlanSelection: false,
751
+ checkoutPending: false,
752
+ commercialState: onboardingPolicy.mode === 'auto_start_trial' ? 'trialing' : 'active',
570
753
  planVersionId: configuredVersion.planVersionId,
571
754
  planId: configuredVersion.planId
572
755
  };
@@ -616,88 +799,55 @@ export class PlanEntitlementService {
616
799
  if (!normalizedAccountId) {
617
800
  throw new TaskforceRuleError('accountId is required', 400, 'ACCOUNT_ID_REQUIRED');
618
801
  }
619
- const signupSettings = this.getSignupMonetizationSettings();
620
- const validation = this.validateSignupMonetizationSettings(signupSettings);
802
+ const onboardingPolicy = this.getOnboardingPolicy();
803
+ const validation = this.validateOnboardingPolicy(onboardingPolicy);
621
804
  const entitlement = this.tryGetAccountEntitlement(normalizedAccountId);
622
- if (entitlement && this.isEntitlementStateAllowed(entitlement.state)) {
805
+ if (!entitlement) {
806
+ return !validation.valid
807
+ ? this.buildBlockedAccessStatus({
808
+ gate: 'misconfigured',
809
+ message: validation.message || 'Onboarding policy is misconfigured.',
810
+ entitlement: null
811
+ })
812
+ : this.buildBlockedAccessStatus({
813
+ gate: 'missing_entitlement',
814
+ message: 'No entitlement is linked to this account.',
815
+ entitlement: null
816
+ });
817
+ }
818
+ if (this.isEntitlementStateAllowed(entitlement.state)) {
623
819
  return {
624
820
  gate: 'ok',
625
821
  hasEntitlement: true,
626
822
  canAccessApp: true,
627
823
  canAccessPlans: true,
628
- signupMonetizationStrategy: signupSettings.strategy,
629
824
  planSelectionRequired: false,
630
825
  message: null,
631
826
  entitlement
632
827
  };
633
828
  }
634
- if (entitlement) {
635
- return {
636
- gate: 'missing_entitlement',
637
- hasEntitlement: true,
638
- canAccessApp: false,
639
- canAccessPlans: true,
640
- signupMonetizationStrategy: signupSettings.strategy,
641
- planSelectionRequired: false,
642
- message: entitlement.state === 'suspended'
643
- ? 'Your subscription is suspended. Update billing to continue.'
644
- : 'Your plan is inactive. Choose a plan to continue.',
645
- entitlement
646
- };
647
- }
648
- if (signupSettings.strategy === 'plan_selection_required') {
649
- const billingRow = this.db.prepare(`
650
- SELECT entitlement_state
651
- FROM user_billing
652
- WHERE tenant_id = ? AND user_id = ?
653
- LIMIT 1
654
- `).get(this.tenantId, normalizedAccountId);
655
- const billingState = String(billingRow?.entitlement_state || '').trim().toLowerCase();
656
- if (billingState === 'pending_plan_selection') {
657
- return {
658
- gate: 'plan_selection_required',
659
- hasEntitlement: false,
660
- canAccessApp: false,
661
- canAccessPlans: true,
662
- signupMonetizationStrategy: signupSettings.strategy,
663
- planSelectionRequired: true,
664
- message: 'Choose a plan to continue.',
665
- entitlement: null
666
- };
667
- }
668
- return {
829
+ if (entitlement.state === 'pending_plan_selection') {
830
+ return this.buildBlockedAccessStatus({
669
831
  gate: 'plan_selection_required',
670
- hasEntitlement: false,
671
- canAccessApp: false,
672
- canAccessPlans: true,
673
- signupMonetizationStrategy: signupSettings.strategy,
674
- planSelectionRequired: true,
675
832
  message: 'Choose a plan to continue.',
676
- entitlement: null
677
- };
833
+ entitlement,
834
+ planSelectionRequired: true
835
+ });
678
836
  }
679
- if (!validation.valid) {
680
- return {
681
- gate: 'misconfigured',
682
- hasEntitlement: false,
683
- canAccessApp: false,
684
- canAccessPlans: true,
685
- signupMonetizationStrategy: signupSettings.strategy,
686
- planSelectionRequired: false,
687
- message: validation.message || 'Signup monetization is misconfigured.',
688
- entitlement: null
689
- };
837
+ if (entitlement.state === 'checkout_pending') {
838
+ return this.buildBlockedAccessStatus({
839
+ gate: 'checkout_pending',
840
+ message: 'Complete checkout to continue.',
841
+ entitlement
842
+ });
690
843
  }
691
- return {
844
+ return this.buildBlockedAccessStatus({
692
845
  gate: 'missing_entitlement',
693
- hasEntitlement: false,
694
- canAccessApp: false,
695
- canAccessPlans: true,
696
- signupMonetizationStrategy: signupSettings.strategy,
697
- planSelectionRequired: false,
698
- message: 'No entitlement is linked to this account.',
699
- entitlement: null
700
- };
846
+ message: entitlement.state === 'suspended'
847
+ ? 'Your subscription is suspended. Update billing to continue.'
848
+ : 'Your plan is inactive. Choose a plan to continue.',
849
+ entitlement
850
+ });
701
851
  }
702
852
  getEffectivePlanVersion(accountId) {
703
853
  const normalizedAccountId = String(accountId || '').trim();
@@ -749,12 +899,7 @@ export class PlanEntitlementService {
749
899
  if (!normalizedAccountId) {
750
900
  throw new TaskforceRuleError('accountId is required', 400, 'ACCOUNT_ID_REQUIRED');
751
901
  }
752
- const row = this.db.prepare(`
753
- SELECT account_id, plan_version_id, entitlement_state, effective_at, effective_until
754
- FROM account_entitlements
755
- WHERE tenant_id = ? AND account_id = ?
756
- LIMIT 1
757
- `).get(this.tenantId, normalizedAccountId);
902
+ const row = this.normalizeExpiredCheckoutPendingRow(normalizedAccountId);
758
903
  if (!row?.account_id || !row.plan_version_id) {
759
904
  throw new TaskforceRuleError('Entitlement not found', 404, 'ENTITLEMENT_NOT_FOUND');
760
905
  }
@@ -847,25 +992,15 @@ export class PlanEntitlementService {
847
992
  }
848
993
  const normalizedMode = String(mode || '').trim().toLowerCase() === 'team' ? 'team' : 'personal';
849
994
  const targetVersion = this.getDefaultPlanVersionByMode(normalizedMode);
850
- const now = new Date().toISOString();
851
- this.db.prepare(`
852
- INSERT INTO user_billing (user_id, tenant_id, entitlement_state, tier, created_at, updated_at)
853
- VALUES (?, ?, ?, ?, ?, ?)
854
- ON CONFLICT (user_id) DO UPDATE SET
855
- entitlement_state = excluded.entitlement_state,
856
- tier = excluded.tier,
857
- updated_at = excluded.updated_at
858
- `).run(normalizedUserId, this.tenantId, 'active', targetVersion.planId, now, now);
859
- const planVersionId = targetVersion.planVersionId;
860
995
  const entitlementState = 'active';
861
- this.upsertCanonicalEntitlementRecord({
996
+ this.transitionCanonicalCommercialState({
862
997
  accountId: normalizedUserId,
863
- planVersionId,
998
+ planVersionId: targetVersion.planVersionId,
864
999
  state: entitlementState,
865
- effectiveAt: now,
1000
+ effectiveAt: new Date().toISOString(),
1001
+ billingMirrorPlanId: targetVersion.planId,
1002
+ source: 'legacy-plan-mode',
866
1003
  updatedBy: 'legacy-plan-mode',
867
- createdAt: now,
868
- updatedAt: now
869
1004
  });
870
1005
  return normalizedMode;
871
1006
  }
@@ -882,76 +1017,18 @@ export class PlanEntitlementService {
882
1017
  entitlementState: 'active'
883
1018
  };
884
1019
  }
885
- // ─── Signup monetization settings ─────────────────────────────────────────
886
- getSignupMonetizationSettings() {
887
- const auth = this.deps.readGlobalConfig().auth || {};
888
- return {
889
- strategy: auth.signupMonetizationStrategy === 'plan_selection_required'
890
- ? 'plan_selection_required'
891
- : 'auto_assign_default',
892
- defaultSignupPlanVersionId: typeof auth.defaultSignupPlanVersionId === 'string'
893
- ? (auth.defaultSignupPlanVersionId.trim() || null)
894
- : null
895
- };
896
- }
897
- validateSignupMonetizationSettings(input) {
898
- const current = this.getSignupMonetizationSettings();
899
- const strategy = input?.strategy === 'plan_selection_required'
900
- ? 'plan_selection_required'
901
- : input?.strategy === 'auto_assign_default'
902
- ? 'auto_assign_default'
903
- : current.strategy;
904
- const defaultSignupPlanVersionId = input?.defaultSignupPlanVersionId !== undefined
905
- ? (typeof input.defaultSignupPlanVersionId === 'string' ? input.defaultSignupPlanVersionId.trim() || null : null)
906
- : current.defaultSignupPlanVersionId;
907
- if (strategy === 'plan_selection_required') {
908
- return { valid: true };
909
- }
910
- if (!defaultSignupPlanVersionId) {
911
- return {
912
- valid: false,
913
- code: 'SIGNUP_PLAN_VERSION_REQUIRED',
914
- message: 'Default signup plan version is required when signup strategy is auto-assign default.'
915
- };
916
- }
917
- const row = this.db.prepare(`
918
- SELECT v.plan_version_id, v.plan_id, COALESCE(p.status, 'active') AS plan_status
919
- FROM plan_versions v
920
- JOIN plan_catalog p ON p.tenant_id = v.tenant_id AND p.plan_id = v.plan_id
921
- WHERE v.tenant_id = ? AND v.plan_version_id = ?
922
- LIMIT 1
923
- `).get(this.tenantId, defaultSignupPlanVersionId);
924
- if (!row?.plan_version_id || !row.plan_id) {
925
- return {
926
- valid: false,
927
- code: 'SIGNUP_PLAN_VERSION_NOT_FOUND',
928
- message: `Configured default signup plan version "${defaultSignupPlanVersionId}" was not found.`,
929
- details: { defaultSignupPlanVersionId }
930
- };
931
- }
932
- if (String(row.plan_status || '').trim().toLowerCase() !== 'active') {
933
- return {
934
- valid: false,
935
- code: 'SIGNUP_PLAN_NOT_ENABLED',
936
- message: `Configured default signup plan version "${defaultSignupPlanVersionId}" belongs to a disabled plan.`,
937
- details: {
938
- defaultSignupPlanVersionId,
939
- planId: String(row.plan_id)
940
- }
941
- };
942
- }
943
- return { valid: true };
944
- }
945
1020
  // ─── Plan catalog admin ───────────────────────────────────────────────────
946
1021
  isValidEntitlementTransition(fromState, toState) {
947
1022
  if (fromState === toState)
948
1023
  return true;
949
1024
  const allowed = {
1025
+ pending_plan_selection: ['checkout_pending', 'trialing', 'active', 'canceled'],
1026
+ checkout_pending: ['pending_plan_selection', 'active', 'trialing', 'canceled'],
950
1027
  trialing: ['active', 'grace', 'canceled'],
951
1028
  active: ['grace', 'suspended', 'canceled'],
952
1029
  grace: ['active', 'suspended', 'canceled'],
953
1030
  suspended: ['active', 'canceled'],
954
- canceled: ['trialing', 'active']
1031
+ canceled: ['pending_plan_selection', 'checkout_pending', 'trialing', 'active']
955
1032
  };
956
1033
  return allowed[fromState].includes(toState);
957
1034
  }
@@ -1358,12 +1435,13 @@ export class PlanEntitlementService {
1358
1435
  const effectiveAt = input.effectiveAt === undefined ? (before?.effectiveAt || null) : (input.effectiveAt ? String(input.effectiveAt) : null);
1359
1436
  const effectiveUntil = input.effectiveUntil === undefined ? (before?.effectiveUntil || null) : (input.effectiveUntil ? String(input.effectiveUntil) : null);
1360
1437
  const now = new Date().toISOString();
1361
- this.upsertCanonicalEntitlementRecord({
1438
+ this.transitionCanonicalCommercialState({
1362
1439
  accountId,
1363
1440
  planVersionId: nextPlanVersionId,
1364
1441
  state: nextState,
1365
1442
  effectiveAt,
1366
1443
  effectiveUntil,
1444
+ source: 'admin',
1367
1445
  updatedBy: String(input.actorUserId || 'system').trim() || 'system',
1368
1446
  createdAt: before?.effectiveAt || now,
1369
1447
  updatedAt: now