@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
@@ -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,77 @@ 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
+ effectiveUntil: null
157
+ });
158
+ return this.readAccountEntitlementRow(accountId);
159
+ }
160
+ isBillingConfirmedTransitionSource(source) {
161
+ return source === 'checkout-confirm' || source === 'stripe-webhook';
162
+ }
163
+ resolveCanonicalTransitionSource(updatedBy) {
164
+ if (updatedBy === 'registration'
165
+ || updatedBy === 'checkout-start'
166
+ || updatedBy === 'checkout-confirm'
167
+ || updatedBy === 'stripe-webhook'
168
+ || updatedBy === 'backfill'
169
+ || updatedBy === 'legacy-plan-mode') {
170
+ return updatedBy;
171
+ }
172
+ return 'system';
173
+ }
174
+ resolvePendingPlanVersionId(planVersionIdRaw) {
175
+ const planVersionId = String(planVersionIdRaw || '').trim();
176
+ if (planVersionId) {
177
+ return this.getPlanVersionSnapshot(planVersionId).planVersionId;
178
+ }
179
+ return this.getDefaultPlanVersionByMode('personal', { allowFallback: true }).planVersionId;
180
+ }
181
+ buildBlockedAccessStatus(input) {
182
+ return {
183
+ gate: input.gate,
184
+ hasEntitlement: Boolean(input.entitlement),
185
+ canAccessApp: false,
186
+ canAccessPlans: true,
187
+ planSelectionRequired: input.planSelectionRequired === true,
188
+ message: input.message,
189
+ entitlement: input.entitlement
190
+ };
191
+ }
115
192
  // ─── Plan defaults repair ─────────────────────────────────────────────────
116
193
  repairPlanDefaultVersionIfNeeded(planIdRaw) {
117
194
  const planId = String(planIdRaw || '').trim().toLowerCase();
@@ -152,42 +229,6 @@ export class PlanEntitlementService {
152
229
  this.repairPlanDefaultVersionIfNeeded(planId);
153
230
  }
154
231
  }
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
232
  normalizeLegacyPlanDataIfNeeded() {
192
233
  this.migrateLegacySeatLimitFeatureConfigToVersionSeatLimit();
193
234
  this.normalizeLegacyBillingMirrorRows();
@@ -301,16 +342,16 @@ export class PlanEntitlementService {
301
342
  if (!accountId)
302
343
  continue;
303
344
  const rawBillingState = String(row.entitlement_state || '').trim().toLowerCase();
304
- if (rawBillingState === 'pending_plan_selection')
305
- continue;
306
345
  const mirroredPlanId = String(row.tier || '').trim().toLowerCase();
307
346
  const planVersion = this.tryGetDefaultPlanVersionByPlanId(mirroredPlanId)
308
347
  || defaultPersonalVersion;
309
- this.upsertCanonicalEntitlementRecord({
348
+ this.transitionCanonicalCommercialState({
310
349
  accountId,
311
350
  planVersionId: planVersion.planVersionId,
312
351
  state: this.resolveCanonicalEntitlementStateFromBilling(row.entitlement_state),
313
352
  effectiveAt: row.billing_created_at ? String(row.billing_created_at) : undefined,
353
+ billingMirrorPlanId: rawBillingState === 'pending_plan_selection' ? null : planVersion.planId,
354
+ source: 'backfill',
314
355
  updatedBy: 'backfill',
315
356
  createdAt: row.billing_created_at ? String(row.billing_created_at) : undefined,
316
357
  updatedAt: row.billing_updated_at ? String(row.billing_updated_at) : undefined
@@ -351,7 +392,39 @@ export class PlanEntitlementService {
351
392
  resolveCanonicalEntitlementStateFromBilling(raw) {
352
393
  return this.normalizeEntitlementState(raw);
353
394
  }
354
- upsertCanonicalEntitlementRecord(input) {
395
+ transitionCanonicalCommercialState(input) {
396
+ const accountId = String(input.accountId || '').trim();
397
+ if (!accountId) {
398
+ throw new TaskforceRuleError('accountId is required', 400, 'ACCOUNT_ID_REQUIRED');
399
+ }
400
+ const nextState = this.normalizeEntitlementState(input.state);
401
+ const existing = this.tryGetAccountEntitlement(accountId);
402
+ const planVersionId = nextState === 'pending_plan_selection'
403
+ ? this.resolvePendingPlanVersionId(input.planVersionId || existing?.planVersionId || null)
404
+ : String(input.planVersionId || existing?.planVersionId || '').trim();
405
+ if (!planVersionId) {
406
+ throw new TaskforceRuleError('planVersionId is required', 400, 'PLAN_VERSION_ID_REQUIRED');
407
+ }
408
+ const planVersion = this.getPlanVersionSnapshot(planVersionId);
409
+ if (existing && !this.isValidEntitlementTransition(existing.state, nextState)) {
410
+ throw new TaskforceRuleError('Invalid entitlement state transition', 409, 'INVALID_STATE_TRANSITION');
411
+ }
412
+ const planHasActivePricing = this.planHasAnyActivePricing(planVersion.planId);
413
+ const planVersionHasActiveFreePricing = this.planVersionHasActiveFreePricing(planVersion.planVersionId);
414
+ if ((nextState === 'active' || nextState === 'grace')
415
+ && planHasActivePricing
416
+ && !planVersionHasActiveFreePricing
417
+ && !this.isBillingConfirmedTransitionSource(input.source)
418
+ && input.source !== 'admin'
419
+ && input.source !== 'backfill'
420
+ && input.source !== 'legacy-plan-mode') {
421
+ throw new TaskforceRuleError('Paid active access can only be activated from billing-confirmed transitions.', 409, 'PAID_ACCESS_REQUIRES_BILLING_CONFIRMATION', {
422
+ accountId,
423
+ planVersionId,
424
+ state: nextState,
425
+ source: input.source
426
+ });
427
+ }
355
428
  const now = new Date().toISOString();
356
429
  const createdAt = input.createdAt || now;
357
430
  const updatedAt = input.updatedAt || now;
@@ -365,7 +438,21 @@ export class PlanEntitlementService {
365
438
  effective_until = excluded.effective_until,
366
439
  updated_by = excluded.updated_by,
367
440
  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);
441
+ `).run(accountId, this.tenantId, planVersion.planVersionId, nextState, input.effectiveAt || now, input.effectiveUntil || null, input.updatedBy, createdAt, updatedAt);
442
+ this.createUserBillingRecord(accountId, {
443
+ entitlementState: nextState,
444
+ planId: input.billingMirrorPlanId !== undefined
445
+ ? input.billingMirrorPlanId
446
+ : (nextState === 'pending_plan_selection' ? null : planVersion.planId),
447
+ effectiveUntil: input.effectiveUntil || null
448
+ });
449
+ return this.getAccountEntitlement(accountId);
450
+ }
451
+ upsertCanonicalEntitlementRecord(input) {
452
+ this.transitionCanonicalCommercialState({
453
+ ...input,
454
+ source: this.resolveCanonicalTransitionSource(input.updatedBy)
455
+ });
369
456
  }
370
457
  isFeatureEnabledForPlanMode(feature) {
371
458
  return Boolean(feature && feature.access !== 'disabled');
@@ -463,6 +550,26 @@ export class PlanEntitlementService {
463
550
  `).get(this.tenantId, planId);
464
551
  return Number(row?.count || 0) > 0;
465
552
  }
553
+ planVersionHasActiveFreePricing(planVersionIdRaw) {
554
+ const planVersionId = String(planVersionIdRaw || '').trim();
555
+ if (!planVersionId)
556
+ return false;
557
+ const row = this.db.prepare(`
558
+ SELECT COUNT(*) AS count
559
+ FROM billing_price_mappings
560
+ WHERE tenant_id = ?
561
+ AND plan_version_id = ?
562
+ AND active = 1
563
+ AND pricing_type = 'free'
564
+ `).get(this.tenantId, planVersionId);
565
+ return Number(row?.count || 0) > 0;
566
+ }
567
+ planVersionIsTrialEligible(planVersionIdRaw) {
568
+ const planVersionId = String(planVersionIdRaw || '').trim();
569
+ if (!planVersionId)
570
+ return false;
571
+ return this.getPlanVersionSnapshot(planVersionId).trialDays > 0;
572
+ }
466
573
  tryGetDefaultPlanVersionByPlanId(planIdRaw) {
467
574
  const planId = String(planIdRaw || '').trim().toLowerCase();
468
575
  if (!planId)
@@ -522,51 +629,169 @@ export class PlanEntitlementService {
522
629
  throw error;
523
630
  }
524
631
  }
525
- resolveRegistrationPlanAssignment(selectedPlanVersionIdRaw) {
526
- const selectedPlanVersionId = String(selectedPlanVersionIdRaw || '').trim();
527
- 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 });
632
+ tryGetImplicitFreeOnboardingPlanVersion() {
633
+ const rows = this.db.prepare(`
634
+ SELECT DISTINCT v.plan_version_id
635
+ FROM plan_versions v
636
+ JOIN plan_catalog p
637
+ ON p.tenant_id = v.tenant_id
638
+ AND p.plan_id = v.plan_id
639
+ JOIN billing_price_mappings bpm
640
+ ON bpm.tenant_id = v.tenant_id
641
+ AND bpm.plan_version_id = v.plan_version_id
642
+ WHERE v.tenant_id = ?
643
+ AND COALESCE(p.status, 'active') = 'active'
644
+ AND bpm.active = 1
645
+ AND bpm.pricing_type = 'free'
646
+ ORDER BY v.is_default DESC, p.updated_at DESC, p.created_at DESC, v.version_number DESC, v.created_at DESC
647
+ `).all(this.tenantId);
648
+ for (const row of rows) {
649
+ const planVersionId = String(row.plan_version_id || '').trim();
650
+ if (!planVersionId)
651
+ continue;
652
+ const version = this.getPlanVersionSnapshot(planVersionId);
653
+ if (!this.isTeamPlanVersion(version)) {
654
+ return version;
537
655
  }
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,
656
+ }
657
+ return null;
658
+ }
659
+ getOnboardingPolicy() {
660
+ const billing = this.deps.readGlobalConfig().billing || {};
661
+ const policy = billing.onboardingPolicy && typeof billing.onboardingPolicy === 'object'
662
+ ? billing.onboardingPolicy
663
+ : null;
664
+ if (policy?.mode === 'require_plan_selection'
665
+ || policy?.mode === 'auto_grant_free'
666
+ || policy?.mode === 'auto_start_trial') {
667
+ return {
668
+ mode: policy.mode,
669
+ planVersionId: typeof policy.planVersionId === 'string'
670
+ ? (policy.planVersionId.trim() || null)
671
+ : null
672
+ };
673
+ }
674
+ const implicitFreeVersion = this.tryGetImplicitFreeOnboardingPlanVersion();
675
+ if (implicitFreeVersion) {
676
+ return {
677
+ mode: 'auto_grant_free',
678
+ planVersionId: implicitFreeVersion.planVersionId
679
+ };
680
+ }
681
+ return {
682
+ mode: 'require_plan_selection',
683
+ planVersionId: null
684
+ };
685
+ }
686
+ validateOnboardingPolicy(input) {
687
+ const current = this.getOnboardingPolicy();
688
+ const mode = input?.mode === 'auto_grant_free' || input?.mode === 'auto_start_trial'
689
+ ? input.mode
690
+ : input?.mode === 'require_plan_selection'
691
+ ? 'require_plan_selection'
692
+ : current.mode;
693
+ const planVersionId = input?.planVersionId !== undefined
694
+ ? (typeof input.planVersionId === 'string' ? input.planVersionId.trim() || null : null)
695
+ : current.planVersionId;
696
+ if (mode === 'require_plan_selection') {
697
+ return { valid: true };
698
+ }
699
+ if (!planVersionId) {
700
+ return {
701
+ valid: false,
702
+ code: 'ONBOARDING_PLAN_VERSION_REQUIRED',
703
+ message: 'An onboarding plan version is required for the selected onboarding mode.'
704
+ };
705
+ }
706
+ const row = this.db.prepare(`
707
+ SELECT v.plan_version_id, v.plan_id, COALESCE(p.status, 'active') AS plan_status
708
+ FROM plan_versions v
709
+ JOIN plan_catalog p ON p.tenant_id = v.tenant_id AND p.plan_id = v.plan_id
710
+ WHERE v.tenant_id = ? AND v.plan_version_id = ?
711
+ LIMIT 1
712
+ `).get(this.tenantId, planVersionId);
713
+ if (!row?.plan_version_id || !row.plan_id) {
714
+ return {
715
+ valid: false,
716
+ code: 'ONBOARDING_PLAN_VERSION_NOT_FOUND',
717
+ message: `Configured onboarding plan version "${planVersionId}" was not found.`,
718
+ details: { planVersionId }
719
+ };
720
+ }
721
+ if (String(row.plan_status || '').trim().toLowerCase() !== 'active') {
722
+ return {
723
+ valid: false,
724
+ code: 'ONBOARDING_PLAN_NOT_ENABLED',
725
+ message: `Configured onboarding plan version "${planVersionId}" belongs to a disabled plan.`,
726
+ details: {
727
+ planVersionId,
541
728
  planId: String(row.plan_id)
542
- });
543
- }
729
+ }
730
+ };
731
+ }
732
+ if (mode === 'auto_grant_free' && !this.planVersionHasActiveFreePricing(planVersionId)) {
733
+ return {
734
+ valid: false,
735
+ code: 'ONBOARDING_PLAN_MUST_BE_FREE',
736
+ message: `Configured onboarding plan version "${planVersionId}" does not have active free pricing.`,
737
+ details: {
738
+ planVersionId,
739
+ planId: String(row.plan_id)
740
+ }
741
+ };
742
+ }
743
+ if (mode === 'auto_start_trial' && !this.planVersionIsTrialEligible(planVersionId)) {
744
+ return {
745
+ valid: false,
746
+ code: 'ONBOARDING_PLAN_TRIAL_REQUIRED',
747
+ message: `Configured onboarding plan version "${planVersionId}" is not trial eligible.`,
748
+ details: {
749
+ planVersionId,
750
+ planId: String(row.plan_id)
751
+ }
752
+ };
753
+ }
754
+ return { valid: true };
755
+ }
756
+ resolveRegistrationOnboardingOutcome(selectedPlanVersionIdRaw) {
757
+ const selectedPlanVersionId = String(selectedPlanVersionIdRaw || '').trim();
758
+ if (selectedPlanVersionId) {
759
+ const planVersion = this.getPlanVersionSnapshot(selectedPlanVersionId);
760
+ const commercialState = this.planVersionHasActiveFreePricing(planVersion.planVersionId)
761
+ ? 'active'
762
+ : 'checkout_pending';
544
763
  return {
545
764
  requiresPlanSelection: false,
546
- planVersionId: String(row.plan_version_id),
547
- planId: String(row.plan_id)
765
+ checkoutPending: commercialState === 'checkout_pending',
766
+ commercialState,
767
+ planVersionId: planVersion.planVersionId,
768
+ planId: planVersion.planId
548
769
  };
549
770
  }
550
- const signupSettings = this.getSignupMonetizationSettings();
551
- if (signupSettings.strategy === 'plan_selection_required') {
771
+ const onboardingPolicy = this.getOnboardingPolicy();
772
+ const validation = this.validateOnboardingPolicy(onboardingPolicy);
773
+ if (!validation.valid) {
774
+ throw new TaskforceRuleError(validation.message || 'Onboarding policy is misconfigured.', 500, validation.code || 'ONBOARDING_POLICY_INVALID', validation.details);
775
+ }
776
+ if (onboardingPolicy.mode === 'require_plan_selection') {
552
777
  return {
553
778
  requiresPlanSelection: true,
779
+ checkoutPending: false,
780
+ commercialState: 'pending_plan_selection',
554
781
  planVersionId: null,
555
782
  planId: null
556
783
  };
557
784
  }
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)
785
+ const configuredVersion = onboardingPolicy.planVersionId
786
+ ? this.getPlanVersionSnapshot(onboardingPolicy.planVersionId)
564
787
  : null;
565
788
  if (!configuredVersion) {
566
- throw new TaskforceRuleError('Default signup plan version is required when signup strategy is auto-assign default.', 500, 'SIGNUP_PLAN_VERSION_REQUIRED');
789
+ throw new TaskforceRuleError('Configured onboarding plan version is required.', 500, 'ONBOARDING_PLAN_VERSION_REQUIRED');
567
790
  }
568
791
  return {
569
792
  requiresPlanSelection: false,
793
+ checkoutPending: false,
794
+ commercialState: onboardingPolicy.mode === 'auto_start_trial' ? 'trialing' : 'active',
570
795
  planVersionId: configuredVersion.planVersionId,
571
796
  planId: configuredVersion.planId
572
797
  };
@@ -578,14 +803,22 @@ export class PlanEntitlementService {
578
803
  const planId = typeof options?.planId === 'string'
579
804
  ? options.planId.trim().toLowerCase()
580
805
  : '';
806
+ const shouldUpdateEffectiveUntil = options ? Object.prototype.hasOwnProperty.call(options, 'effectiveUntil') : false;
807
+ const effectiveUntil = shouldUpdateEffectiveUntil
808
+ ? (typeof options?.effectiveUntil === 'string' ? options.effectiveUntil : null)
809
+ : null;
581
810
  this.db.prepare(`
582
- INSERT INTO user_billing (user_id, tenant_id, entitlement_state, tier, created_at, updated_at)
583
- VALUES (?, ?, ?, ?, ?, ?)
811
+ INSERT INTO user_billing (user_id, tenant_id, entitlement_state, tier, effective_until, created_at, updated_at)
812
+ VALUES (?, ?, ?, ?, ?, ?, ?)
584
813
  ON CONFLICT (user_id) DO UPDATE SET
585
814
  entitlement_state = excluded.entitlement_state,
586
815
  tier = excluded.tier,
816
+ effective_until = CASE
817
+ WHEN ? THEN excluded.effective_until
818
+ ELSE user_billing.effective_until
819
+ END,
587
820
  updated_at = excluded.updated_at
588
- `).run(userId, this.tenantId, entitlementState, planId, now, now);
821
+ `).run(userId, this.tenantId, entitlementState, planId, effectiveUntil, now, now, shouldUpdateEffectiveUntil ? 1 : 0);
589
822
  }
590
823
  getUserEntitlement(userId) {
591
824
  const account = this.getAccountEntitlement(userId);
@@ -616,88 +849,55 @@ export class PlanEntitlementService {
616
849
  if (!normalizedAccountId) {
617
850
  throw new TaskforceRuleError('accountId is required', 400, 'ACCOUNT_ID_REQUIRED');
618
851
  }
619
- const signupSettings = this.getSignupMonetizationSettings();
620
- const validation = this.validateSignupMonetizationSettings(signupSettings);
852
+ const onboardingPolicy = this.getOnboardingPolicy();
853
+ const validation = this.validateOnboardingPolicy(onboardingPolicy);
621
854
  const entitlement = this.tryGetAccountEntitlement(normalizedAccountId);
622
- if (entitlement && this.isEntitlementStateAllowed(entitlement.state)) {
855
+ if (!entitlement) {
856
+ return !validation.valid
857
+ ? this.buildBlockedAccessStatus({
858
+ gate: 'misconfigured',
859
+ message: validation.message || 'Onboarding policy is misconfigured.',
860
+ entitlement: null
861
+ })
862
+ : this.buildBlockedAccessStatus({
863
+ gate: 'missing_entitlement',
864
+ message: 'No entitlement is linked to this account.',
865
+ entitlement: null
866
+ });
867
+ }
868
+ if (this.isEntitlementStateAllowed(entitlement.state)) {
623
869
  return {
624
870
  gate: 'ok',
625
871
  hasEntitlement: true,
626
872
  canAccessApp: true,
627
873
  canAccessPlans: true,
628
- signupMonetizationStrategy: signupSettings.strategy,
629
874
  planSelectionRequired: false,
630
875
  message: null,
631
876
  entitlement
632
877
  };
633
878
  }
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 {
879
+ if (entitlement.state === 'pending_plan_selection') {
880
+ return this.buildBlockedAccessStatus({
669
881
  gate: 'plan_selection_required',
670
- hasEntitlement: false,
671
- canAccessApp: false,
672
- canAccessPlans: true,
673
- signupMonetizationStrategy: signupSettings.strategy,
674
- planSelectionRequired: true,
675
882
  message: 'Choose a plan to continue.',
676
- entitlement: null
677
- };
883
+ entitlement,
884
+ planSelectionRequired: true
885
+ });
678
886
  }
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
- };
887
+ if (entitlement.state === 'checkout_pending') {
888
+ return this.buildBlockedAccessStatus({
889
+ gate: 'checkout_pending',
890
+ message: 'Complete checkout to continue.',
891
+ entitlement
892
+ });
690
893
  }
691
- return {
894
+ return this.buildBlockedAccessStatus({
692
895
  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
- };
896
+ message: entitlement.state === 'suspended'
897
+ ? 'Your subscription is suspended. Update billing to continue.'
898
+ : 'Your plan is inactive. Choose a plan to continue.',
899
+ entitlement
900
+ });
701
901
  }
702
902
  getEffectivePlanVersion(accountId) {
703
903
  const normalizedAccountId = String(accountId || '').trim();
@@ -749,12 +949,7 @@ export class PlanEntitlementService {
749
949
  if (!normalizedAccountId) {
750
950
  throw new TaskforceRuleError('accountId is required', 400, 'ACCOUNT_ID_REQUIRED');
751
951
  }
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);
952
+ const row = this.normalizeExpiredCheckoutPendingRow(normalizedAccountId);
758
953
  if (!row?.account_id || !row.plan_version_id) {
759
954
  throw new TaskforceRuleError('Entitlement not found', 404, 'ENTITLEMENT_NOT_FOUND');
760
955
  }
@@ -847,25 +1042,15 @@ export class PlanEntitlementService {
847
1042
  }
848
1043
  const normalizedMode = String(mode || '').trim().toLowerCase() === 'team' ? 'team' : 'personal';
849
1044
  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
1045
  const entitlementState = 'active';
861
- this.upsertCanonicalEntitlementRecord({
1046
+ this.transitionCanonicalCommercialState({
862
1047
  accountId: normalizedUserId,
863
- planVersionId,
1048
+ planVersionId: targetVersion.planVersionId,
864
1049
  state: entitlementState,
865
- effectiveAt: now,
1050
+ effectiveAt: new Date().toISOString(),
1051
+ billingMirrorPlanId: targetVersion.planId,
1052
+ source: 'legacy-plan-mode',
866
1053
  updatedBy: 'legacy-plan-mode',
867
- createdAt: now,
868
- updatedAt: now
869
1054
  });
870
1055
  return normalizedMode;
871
1056
  }
@@ -882,76 +1067,18 @@ export class PlanEntitlementService {
882
1067
  entitlementState: 'active'
883
1068
  };
884
1069
  }
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
1070
  // ─── Plan catalog admin ───────────────────────────────────────────────────
946
1071
  isValidEntitlementTransition(fromState, toState) {
947
1072
  if (fromState === toState)
948
1073
  return true;
949
1074
  const allowed = {
1075
+ pending_plan_selection: ['checkout_pending', 'trialing', 'active', 'canceled'],
1076
+ checkout_pending: ['pending_plan_selection', 'active', 'trialing', 'canceled'],
950
1077
  trialing: ['active', 'grace', 'canceled'],
951
1078
  active: ['grace', 'suspended', 'canceled'],
952
1079
  grace: ['active', 'suspended', 'canceled'],
953
1080
  suspended: ['active', 'canceled'],
954
- canceled: ['trialing', 'active']
1081
+ canceled: ['pending_plan_selection', 'checkout_pending', 'trialing', 'active']
955
1082
  };
956
1083
  return allowed[fromState].includes(toState);
957
1084
  }
@@ -1358,12 +1485,13 @@ export class PlanEntitlementService {
1358
1485
  const effectiveAt = input.effectiveAt === undefined ? (before?.effectiveAt || null) : (input.effectiveAt ? String(input.effectiveAt) : null);
1359
1486
  const effectiveUntil = input.effectiveUntil === undefined ? (before?.effectiveUntil || null) : (input.effectiveUntil ? String(input.effectiveUntil) : null);
1360
1487
  const now = new Date().toISOString();
1361
- this.upsertCanonicalEntitlementRecord({
1488
+ this.transitionCanonicalCommercialState({
1362
1489
  accountId,
1363
1490
  planVersionId: nextPlanVersionId,
1364
1491
  state: nextState,
1365
1492
  effectiveAt,
1366
1493
  effectiveUntil,
1494
+ source: 'admin',
1367
1495
  updatedBy: String(input.actorUserId || 'system').trim() || 'system',
1368
1496
  createdAt: before?.effectiveAt || now,
1369
1497
  updatedAt: now