@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.
- package/dist/Taskforce.module.css +11 -0
- package/dist/TaskforceCore.js +59 -9
- package/dist/TaskforceCore.test.js +717 -14
- package/dist/components/task/TaskKanban.test.js +17 -0
- package/dist/components/views/PlansPage.d.ts +3 -1
- package/dist/components/views/PlansPage.js +110 -51
- package/dist/components/views/PlansPage.test.d.ts +1 -0
- package/dist/components/views/PlansPage.test.js +100 -0
- package/dist/components/views/StandaloneLayout.d.ts +1 -0
- package/dist/components/views/StandaloneLayout.js +106 -151
- package/dist/components/views/standalone/modals/AccountHubModal.d.ts +8 -3
- package/dist/components/views/standalone/modals/AccountHubModal.js +16 -2
- package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +1 -0
- package/dist/components/views/standalone/modals/AccountHubModal.test.js +26 -0
- package/dist/core/EntitlementsPolicy.test.js +2 -2
- package/dist/core/GlobalSettingsService.js +78 -18
- package/dist/core/PlanEntitlementService.d.ts +29 -5
- package/dist/core/PlanEntitlementService.js +297 -219
- package/dist/core/SignupMonetizationSettings.test.js +173 -49
- package/dist/core/SystemAdmin.test.js +186 -96
- package/dist/core/Taskforce.d.ts +51 -10
- package/dist/core/Taskforce.js +218 -49
- package/dist/core/Taskforce.mcpAuth.test.js +0 -2
- package/dist/core/UserProfileAvatarDrafts.test.js +12 -3
- package/dist/core/types.d.ts +11 -12
- package/dist/hooks/useSyncOrchestrator.js +17 -3
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +97 -5
- package/dist/hooks/useTaskforce.d.ts +3 -0
- package/dist/hooks/useTaskforce.js +9 -4
- package/dist/mcp/runtime.js +90 -29
- package/dist/mcp/runtime.test.js +272 -81
- package/dist/migrations/billingSchemaParity.test.js +3 -0
- package/dist/migrations/taskSchemaMigrations.js +15 -0
- package/dist/server/index.cookieProxy.test.js +1 -0
- package/dist/server/index.js +2 -0
- package/dist/server/index.test.js +2 -1
- package/dist/server/mockStripe.d.ts +1 -0
- package/dist/server/mockStripe.js +2 -0
- package/dist/server/routes/admin.js +80 -23
- package/dist/server/routes/auth.d.ts +1 -0
- package/dist/server/routes/auth.js +194 -53
- package/dist/server/routes/billing.js +506 -23
- package/dist/server/routes/billing.test.js +937 -45
- package/dist/server/routes/shared.js +5 -3
- package/dist/server/routes/sync.integration.test.js +160 -0
- package/dist/server/routes/sync.js +15 -5
- package/dist/server/routes.js +3 -3
- package/dist/server/routes.test.js +287 -76
- package/dist/sync/cloudSyncApi.d.ts +1 -0
- package/dist/sync/contentSyncState.d.ts +10 -0
- package/dist/sync/contentSyncState.js +277 -16
- package/dist/sync/syncService.d.ts +1 -0
- package/dist/sync/syncService.js +21 -8
- package/dist/sync/workspacePullFeed.d.ts +10 -0
- package/dist/sync/workspacePullFeed.js +111 -63
- package/dist/sync/workspacePullFeed.test.js +112 -0
- package/dist/ui/assets/{AgentsModule-JG5jc3he.js → AgentsModule-CpsTmrIW.js} +1 -1
- package/dist/ui/assets/{AnnotatedAttachmentWorkspace-Dvi02Y7q.js → AnnotatedAttachmentWorkspace-C_TmXZwA.js} +1 -1
- package/dist/ui/assets/{DocumentWorkspace-iys3zfca.js → DocumentWorkspace-C3GyzrWm.js} +1 -1
- package/dist/ui/assets/{InitiativesModule-DJvLJI3A.js → InitiativesModule-4-Rh2K2n.js} +1 -1
- package/dist/ui/assets/PlansPage-BP7AYOqr.js +1 -0
- package/dist/ui/assets/{TaskSettings-Bqd4cCzW.js → TaskSettings-BOC5F6Ag.js} +1 -1
- package/dist/ui/assets/{WorkflowsModule-BguHeQaV.js → WorkflowsModule-CGk9s3NJ.js} +1 -1
- package/dist/ui/assets/index-CLIMgR6g.js +6 -0
- package/dist/ui/assets/index-DneETxcu.css +1 -0
- package/dist/ui/assets/{vendor-icons-C8EED3Oh.js → vendor-icons-pWocEipT.js} +1 -1
- package/dist/ui/index.html +3 -3
- package/dist/utils/accountProfileSummaryCache.d.ts +31 -0
- package/dist/utils/accountProfileSummaryCache.js +97 -0
- package/dist/utils/accountProfileSummaryCache.test.d.ts +1 -0
- package/dist/utils/accountProfileSummaryCache.test.js +63 -0
- package/package.json +3 -3
- package/dist/ui/assets/PlansPage-BMPlpFpw.js +0 -1
- package/dist/ui/assets/index-MXeWoebC.css +0 -1
- package/dist/ui/assets/index-YJ4k5QNa.js +0 -6
- package/dist/utils/billingStatusCache.d.ts +0 -21
- 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 === '
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
547
|
-
|
|
723
|
+
checkoutPending: commercialState === 'checkout_pending',
|
|
724
|
+
commercialState,
|
|
725
|
+
planVersionId: planVersion.planVersionId,
|
|
726
|
+
planId: planVersion.planId
|
|
548
727
|
};
|
|
549
728
|
}
|
|
550
|
-
const
|
|
551
|
-
|
|
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
|
|
559
|
-
|
|
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('
|
|
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
|
|
620
|
-
const validation = this.
|
|
802
|
+
const onboardingPolicy = this.getOnboardingPolicy();
|
|
803
|
+
const validation = this.validateOnboardingPolicy(onboardingPolicy);
|
|
621
804
|
const entitlement = this.tryGetAccountEntitlement(normalizedAccountId);
|
|
622
|
-
if (entitlement
|
|
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
|
|
677
|
-
|
|
833
|
+
entitlement,
|
|
834
|
+
planSelectionRequired: true
|
|
835
|
+
});
|
|
678
836
|
}
|
|
679
|
-
if (
|
|
680
|
-
return {
|
|
681
|
-
gate: '
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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.
|
|
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.
|
|
996
|
+
this.transitionCanonicalCommercialState({
|
|
862
997
|
accountId: normalizedUserId,
|
|
863
|
-
planVersionId,
|
|
998
|
+
planVersionId: targetVersion.planVersionId,
|
|
864
999
|
state: entitlementState,
|
|
865
|
-
effectiveAt:
|
|
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.
|
|
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
|