@taskforcehq/taskforce 0.3.307 → 0.3.308

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 (50) hide show
  1. package/dist/components/views/PlanComparisonPage.d.ts +20 -15
  2. package/dist/components/views/PlanComparisonPage.js +214 -83
  3. package/dist/components/views/PlanComparisonPage.test.js +249 -14
  4. package/dist/components/views/PlansPage.js +15 -9
  5. package/dist/components/views/PlansPage.test.js +4 -2
  6. package/dist/core/GlobalSettingsService.js +18 -0
  7. package/dist/core/GlobalSettingsService.test.d.ts +1 -0
  8. package/dist/core/GlobalSettingsService.test.js +45 -0
  9. package/dist/core/PlanEntitlementService.d.ts +20 -0
  10. package/dist/core/PlanEntitlementService.js +215 -19
  11. package/dist/core/PlanFeatureCatalog.test.js +55 -0
  12. package/dist/core/Taskforce.d.ts +14 -0
  13. package/dist/core/Taskforce.js +6 -0
  14. package/dist/core/types.d.ts +3 -0
  15. package/dist/migrations/taskSchemaMigrations.js +37 -15
  16. package/dist/server/routes/admin.js +146 -5
  17. package/dist/server/routes/authSupport.d.ts +1 -0
  18. package/dist/server/routes/authSupport.js +2 -1
  19. package/dist/server/routes/billing.d.ts +1 -0
  20. package/dist/server/routes/billing.js +105 -63
  21. package/dist/server/routes/billing.test.js +159 -11
  22. package/dist/server/routes.test.js +91 -10
  23. package/dist/ui/agent-logos/ChatGPT.png +0 -0
  24. package/dist/ui/agent-logos/Gemini CLI.jpeg +0 -0
  25. package/dist/ui/agent-logos/antigravity.jpeg +0 -0
  26. package/dist/ui/agent-logos/claude-code.jpeg +0 -0
  27. package/dist/ui/agent-logos/codex.jpeg +0 -0
  28. package/dist/ui/agent-logos/cursor.png +0 -0
  29. package/dist/ui/agent-logos/openclaw.jpeg +0 -0
  30. package/dist/ui/agent-logos/windsurf.png +0 -0
  31. package/dist/ui/assets/{AgentsModule-2y82_3DO.js → AgentsModule-BS4Pi_nC.js} +1 -1
  32. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BTLZZCiQ.js → AnnotatedAttachmentWorkspace-CCckzWYZ.js} +1 -1
  33. package/dist/ui/assets/{ContextAttachmentManager-D2epuNnG.js → ContextAttachmentManager-CfG_ER7C.js} +1 -1
  34. package/dist/ui/assets/{DocumentWorkspace-sVa-3Do6.js → DocumentWorkspace-BVaWPA25.js} +1 -1
  35. package/dist/ui/assets/{EntityActivityTimeline-Zods27y_.js → EntityActivityTimeline-DSj0ThaK.js} +1 -1
  36. package/dist/ui/assets/{InitiativesModule-D4XseTwW.js → InitiativesModule-AxMkWtxH.js} +1 -1
  37. package/dist/ui/assets/PlansPage-B_z-D6Nh.css +1 -0
  38. package/dist/ui/assets/PlansPage-Ndt7mYfo.js +1 -0
  39. package/dist/ui/assets/{TaskContextUpload-uC5qx4Bv.js → TaskContextUpload-Dw4rTF4i.js} +1 -1
  40. package/dist/ui/assets/{TaskSettings-CqvPAsTv.js → TaskSettings-f2ga42_V.js} +1 -1
  41. package/dist/ui/assets/{WorkflowsModule-oOA9bcdK.js → WorkflowsModule-E4UL3mov.js} +1 -1
  42. package/dist/ui/assets/documentReferences-BOUcJm6-.js +1 -0
  43. package/dist/ui/assets/{index-CYyodXsg.css → index-BvAbqx5Q.css} +1 -1
  44. package/dist/ui/assets/{index-607WPo_X.js → index-MAHKvFqp.js} +4 -4
  45. package/dist/ui/index.html +2 -2
  46. package/dist/ui/og-image.png +0 -0
  47. package/package.json +1 -1
  48. package/dist/ui/assets/PlansPage-D5AcbO0L.js +0 -1
  49. package/dist/ui/assets/PlansPage-Dvqsz5zc.css +0 -1
  50. package/dist/ui/assets/documentReferences-DPZuTgHh.js +0 -1
@@ -4,6 +4,7 @@ const LEGACY_REMOVED_PLAN_FEATURE_KEYS = Object.freeze([
4
4
  'documents.workspace_access'
5
5
  ]);
6
6
  const LEGACY_WORKSPACES_FEATURE_KEY = 'workspace.switching';
7
+ const CUSTOM_PLAN_FEATURE_KEY_PREFIX = 'marketing.';
7
8
  /**
8
9
  * Handles all plan catalog, plan version, and account entitlement logic.
9
10
  * Extracted from Taskforce.ts to reduce monolith size.
@@ -49,9 +50,105 @@ export class PlanEntitlementService {
49
50
  return null;
50
51
  return Math.max(0, Math.floor(numeric));
51
52
  }
53
+ normalizeOptionalCatalogDescriptionVisible(raw) {
54
+ if (raw === null || raw === undefined || raw === '')
55
+ return null;
56
+ return raw === false || raw === 0 || raw === '0' ? false : true;
57
+ }
58
+ isBuiltInPlanFeatureKey(featureKey) {
59
+ return DEFAULT_PLAN_FEATURE_CATALOG.some((feature) => feature.featureKey === featureKey);
60
+ }
61
+ normalizeRequiredCatalogLabel(raw) {
62
+ return String(raw ?? '').trim();
63
+ }
64
+ slugifyCustomPlanFeatureLabel(label) {
65
+ const base = label
66
+ .trim()
67
+ .toLowerCase()
68
+ .replace(/[^a-z0-9]+/g, '_')
69
+ .replace(/^_+|_+$/g, '');
70
+ return base || generateUuidV7().replace(/-/g, '_');
71
+ }
72
+ featureKeyExists(featureKey) {
73
+ if (this.isBuiltInPlanFeatureKey(featureKey))
74
+ return true;
75
+ const row = this.db.prepare(`
76
+ SELECT 1
77
+ FROM plan_feature_catalog_overrides
78
+ WHERE tenant_id = ? AND feature_key = ?
79
+ LIMIT 1
80
+ `).get(this.tenantId, featureKey);
81
+ return Boolean(row);
82
+ }
83
+ generateUniqueCustomPlanFeatureKey(label) {
84
+ const base = `${CUSTOM_PLAN_FEATURE_KEY_PREFIX}${this.slugifyCustomPlanFeatureLabel(label)}`;
85
+ let featureKey = base;
86
+ let suffix = 2;
87
+ while (this.featureKeyExists(featureKey)) {
88
+ featureKey = `${base}_${suffix}`;
89
+ suffix += 1;
90
+ }
91
+ return featureKey;
92
+ }
93
+ listCustomPlanFeatureCatalogEntries() {
94
+ const rows = this.db.prepare(`
95
+ SELECT feature_key, label, description, public_label, public_description, public_description_visible, public_display_order
96
+ FROM plan_feature_catalog_overrides
97
+ WHERE tenant_id = ? AND is_custom = 1
98
+ ORDER BY updated_at ASC, created_at ASC, feature_key ASC
99
+ `).all(this.tenantId);
100
+ const features = [];
101
+ for (const row of rows) {
102
+ const featureKey = this.canonicalizePlanFeatureKey(row.feature_key);
103
+ const label = this.normalizeRequiredCatalogLabel(row.label);
104
+ if (!featureKey || !label)
105
+ continue;
106
+ features.push({
107
+ featureKey,
108
+ label,
109
+ description: this.normalizeRequiredCatalogLabel(row.description),
110
+ isCustom: true,
111
+ publicLabel: this.normalizeOptionalCatalogMarketingCopy(row.public_label),
112
+ publicDescription: this.normalizeOptionalCatalogMarketingCopy(row.public_description),
113
+ publicDescriptionVisible: this.normalizeOptionalCatalogDescriptionVisible(row.public_description_visible),
114
+ publicDisplayOrder: this.normalizeOptionalCatalogDisplayOrder(row.public_display_order),
115
+ allowedAccessModes: ['enabled', 'disabled'],
116
+ configTemplate: {}
117
+ });
118
+ }
119
+ return features;
120
+ }
121
+ getCustomPlanFeatureCatalogEntry(featureKeyRaw) {
122
+ const featureKey = this.canonicalizePlanFeatureKey(featureKeyRaw);
123
+ if (!featureKey)
124
+ return null;
125
+ const row = this.db.prepare(`
126
+ SELECT feature_key, label, description, public_label, public_description, public_description_visible, public_display_order
127
+ FROM plan_feature_catalog_overrides
128
+ WHERE tenant_id = ? AND feature_key = ? AND is_custom = 1
129
+ LIMIT 1
130
+ `).get(this.tenantId, featureKey);
131
+ if (!row)
132
+ return null;
133
+ const label = this.normalizeRequiredCatalogLabel(row.label);
134
+ if (!label)
135
+ return null;
136
+ return {
137
+ featureKey,
138
+ label,
139
+ description: this.normalizeRequiredCatalogLabel(row.description),
140
+ isCustom: true,
141
+ publicLabel: this.normalizeOptionalCatalogMarketingCopy(row.public_label),
142
+ publicDescription: this.normalizeOptionalCatalogMarketingCopy(row.public_description),
143
+ publicDescriptionVisible: this.normalizeOptionalCatalogDescriptionVisible(row.public_description_visible),
144
+ publicDisplayOrder: this.normalizeOptionalCatalogDisplayOrder(row.public_display_order),
145
+ allowedAccessModes: ['enabled', 'disabled'],
146
+ configTemplate: {}
147
+ };
148
+ }
52
149
  listPlanFeatureCatalogOverrideMap() {
53
150
  const rows = this.db.prepare(`
54
- SELECT feature_key, public_label, public_description, public_display_order
151
+ SELECT feature_key, public_label, public_description, public_description_visible, public_display_order, is_custom
55
152
  FROM plan_feature_catalog_overrides
56
153
  WHERE tenant_id = ?
57
154
  `).all(this.tenantId);
@@ -61,12 +158,15 @@ export class PlanEntitlementService {
61
158
  const featureKey = this.canonicalizePlanFeatureKey(originalFeatureKey);
62
159
  if (!featureKey)
63
160
  continue;
161
+ if (Number(row.is_custom || 0) === 1)
162
+ continue;
64
163
  if (featureKey === WORKSPACES_FEATURE_KEY && originalFeatureKey === LEGACY_WORKSPACES_FEATURE_KEY && overrides.has(featureKey)) {
65
164
  continue;
66
165
  }
67
166
  overrides.set(featureKey, {
68
167
  publicLabel: this.normalizeOptionalCatalogMarketingCopy(row.public_label),
69
168
  publicDescription: this.normalizeOptionalCatalogMarketingCopy(row.public_description),
169
+ publicDescriptionVisible: this.normalizeOptionalCatalogDescriptionVisible(row.public_description_visible),
70
170
  publicDisplayOrder: this.normalizeOptionalCatalogDisplayOrder(row.public_display_order)
71
171
  });
72
172
  }
@@ -689,6 +789,7 @@ export class PlanEntitlementService {
689
789
  WHERE bpm.tenant_id = ?
690
790
  AND pv.plan_id = ?
691
791
  AND bpm.active = 1
792
+ AND COALESCE(bpm.pricing_audience, 'public') = 'public'
692
793
  `).get(this.tenantId, planId);
693
794
  return Number(row?.count || 0) > 0;
694
795
  }
@@ -702,6 +803,7 @@ export class PlanEntitlementService {
702
803
  WHERE tenant_id = ?
703
804
  AND plan_version_id = ?
704
805
  AND active = 1
806
+ AND COALESCE(pricing_audience, 'public') = 'public'
705
807
  AND pricing_type = 'free'
706
808
  `).get(this.tenantId, planVersionId);
707
809
  return Number(row?.count || 0) > 0;
@@ -1054,7 +1156,7 @@ export class PlanEntitlementService {
1054
1156
  const mappingRow = this.db.prepare(`
1055
1157
  SELECT plan_version_id
1056
1158
  FROM billing_price_mappings
1057
- WHERE tenant_id = ? AND stripe_price_id = ? AND active = 1
1159
+ WHERE tenant_id = ? AND stripe_price_id = ? AND active = 1 AND COALESCE(pricing_audience, 'public') = 'public'
1058
1160
  ORDER BY updated_at DESC, created_at DESC
1059
1161
  LIMIT 1
1060
1162
  `).get(this.tenantId, stripePriceId);
@@ -1490,31 +1592,104 @@ export class PlanEntitlementService {
1490
1592
  }
1491
1593
  listPlanFeatureCatalog() {
1492
1594
  const overrides = this.listPlanFeatureCatalogOverrideMap();
1493
- return DEFAULT_PLAN_FEATURE_CATALOG.map((feature) => ({
1494
- featureKey: feature.featureKey,
1495
- label: feature.label,
1496
- description: feature.description,
1497
- publicLabel: overrides.get(feature.featureKey)?.publicLabel ?? feature.publicLabel ?? null,
1498
- publicDescription: overrides.get(feature.featureKey)?.publicDescription ?? feature.publicDescription ?? null,
1499
- publicDisplayOrder: overrides.get(feature.featureKey)?.publicDisplayOrder ?? feature.publicDisplayOrder ?? null,
1500
- allowedAccessModes: [...feature.allowedAccessModes],
1501
- configTemplate: { ...feature.configTemplate },
1502
- ...(feature.dependsOn?.length ? { dependsOn: [...feature.dependsOn] } : {})
1503
- }));
1595
+ return [
1596
+ ...DEFAULT_PLAN_FEATURE_CATALOG.map((feature) => ({
1597
+ featureKey: feature.featureKey,
1598
+ label: feature.label,
1599
+ description: feature.description,
1600
+ isCustom: false,
1601
+ publicLabel: overrides.get(feature.featureKey)?.publicLabel ?? feature.publicLabel ?? null,
1602
+ publicDescription: overrides.get(feature.featureKey)?.publicDescription ?? feature.publicDescription ?? null,
1603
+ publicDescriptionVisible: overrides.get(feature.featureKey)?.publicDescriptionVisible ?? feature.publicDescriptionVisible ?? null,
1604
+ publicDisplayOrder: overrides.get(feature.featureKey)?.publicDisplayOrder ?? feature.publicDisplayOrder ?? null,
1605
+ allowedAccessModes: [...feature.allowedAccessModes],
1606
+ configTemplate: { ...feature.configTemplate },
1607
+ ...(feature.dependsOn?.length ? { dependsOn: [...feature.dependsOn] } : {})
1608
+ })),
1609
+ ...this.listCustomPlanFeatureCatalogEntries()
1610
+ ];
1611
+ }
1612
+ createCustomPlanFeatureCatalogEntry(input) {
1613
+ const label = this.normalizeRequiredCatalogLabel(input.label);
1614
+ const description = this.normalizeRequiredCatalogLabel(input.description);
1615
+ if (!label)
1616
+ throw new TaskforceRuleError('Feature label is required', 400, 'PLAN_FEATURE_LABEL_REQUIRED');
1617
+ const featureKey = this.generateUniqueCustomPlanFeatureKey(label);
1618
+ const publicLabel = this.normalizeOptionalCatalogMarketingCopy(input.publicLabel);
1619
+ const publicDescription = this.normalizeOptionalCatalogMarketingCopy(input.publicDescription);
1620
+ const publicDescriptionVisible = this.normalizeOptionalCatalogDescriptionVisible(input.publicDescriptionVisible);
1621
+ const publicDisplayOrder = this.normalizeOptionalCatalogDisplayOrder(input.publicDisplayOrder);
1622
+ const now = new Date().toISOString();
1623
+ this.db.prepare(`
1624
+ INSERT INTO plan_feature_catalog_overrides (
1625
+ tenant_id,
1626
+ feature_key,
1627
+ label,
1628
+ description,
1629
+ is_custom,
1630
+ public_label,
1631
+ public_description,
1632
+ public_description_visible,
1633
+ public_display_order,
1634
+ created_at,
1635
+ updated_at
1636
+ )
1637
+ VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
1638
+ `).run(this.tenantId, featureKey, label, description, publicLabel, publicDescription, publicDescriptionVisible === null ? null : (publicDescriptionVisible ? 1 : 0), publicDisplayOrder, now, now);
1639
+ return this.getCustomPlanFeatureCatalogEntry(featureKey) || {
1640
+ featureKey,
1641
+ label,
1642
+ description,
1643
+ isCustom: true,
1644
+ publicLabel,
1645
+ publicDescription,
1646
+ publicDescriptionVisible,
1647
+ publicDisplayOrder,
1648
+ allowedAccessModes: ['enabled', 'disabled'],
1649
+ configTemplate: {}
1650
+ };
1651
+ }
1652
+ deleteCustomPlanFeatureCatalogEntry(featureKeyRaw) {
1653
+ const featureKey = this.canonicalizePlanFeatureKey(featureKeyRaw);
1654
+ if (!featureKey)
1655
+ throw new TaskforceRuleError('featureKey is required', 400, 'FEATURE_KEY_REQUIRED');
1656
+ if (this.isBuiltInPlanFeatureKey(featureKey)) {
1657
+ throw new TaskforceRuleError('Built-in plan features cannot be deleted', 400, 'PLAN_FEATURE_DELETE_BUILTIN_FORBIDDEN');
1658
+ }
1659
+ const existing = this.getCustomPlanFeatureCatalogEntry(featureKey);
1660
+ if (!existing)
1661
+ throw new TaskforceRuleError('Unknown custom plan feature key', 404, 'PLAN_FEATURE_NOT_FOUND');
1662
+ const tx = this.db.transaction(() => {
1663
+ this.db.prepare(`
1664
+ DELETE FROM plan_feature_catalog_overrides
1665
+ WHERE tenant_id = ? AND feature_key = ? AND is_custom = 1
1666
+ `).run(this.tenantId, featureKey);
1667
+ this.db.prepare(`
1668
+ DELETE FROM plan_feature_matrix
1669
+ WHERE tenant_id = ? AND feature_key = ?
1670
+ `).run(this.tenantId, featureKey);
1671
+ });
1672
+ tx();
1504
1673
  }
1505
1674
  updatePlanFeatureCatalogMarketingCopy(input) {
1506
1675
  const featureKey = this.canonicalizePlanFeatureKey(input.featureKey);
1507
1676
  if (!featureKey)
1508
1677
  throw new TaskforceRuleError('featureKey is required', 400, 'FEATURE_KEY_REQUIRED');
1509
1678
  const catalogFeature = DEFAULT_PLAN_FEATURE_CATALOG.find((feature) => feature.featureKey === featureKey);
1510
- if (!catalogFeature)
1679
+ const customFeature = this.getCustomPlanFeatureCatalogEntry(featureKey);
1680
+ if (!catalogFeature && !customFeature)
1511
1681
  throw new TaskforceRuleError('Unknown plan feature key', 404, 'PLAN_FEATURE_NOT_FOUND');
1682
+ const label = this.normalizeRequiredCatalogLabel(input.label ?? customFeature?.label ?? '');
1683
+ const description = this.normalizeRequiredCatalogLabel(input.description ?? customFeature?.description ?? '');
1684
+ if (customFeature && !label)
1685
+ throw new TaskforceRuleError('Feature label is required', 400, 'PLAN_FEATURE_LABEL_REQUIRED');
1512
1686
  const publicLabel = this.normalizeOptionalCatalogMarketingCopy(input.publicLabel);
1513
1687
  const publicDescription = this.normalizeOptionalCatalogMarketingCopy(input.publicDescription);
1688
+ const publicDescriptionVisible = this.normalizeOptionalCatalogDescriptionVisible(input.publicDescriptionVisible);
1514
1689
  const publicDisplayOrder = this.normalizeOptionalCatalogDisplayOrder(input.publicDisplayOrder);
1515
1690
  const now = new Date().toISOString();
1516
1691
  const tx = this.db.transaction(() => {
1517
- if (!publicLabel && !publicDescription && publicDisplayOrder === null) {
1692
+ if (!customFeature && !publicLabel && !publicDescription && publicDescriptionVisible === null && publicDisplayOrder === null) {
1518
1693
  this.db.prepare(`
1519
1694
  DELETE FROM plan_feature_catalog_overrides
1520
1695
  WHERE tenant_id = ? AND feature_key = ?
@@ -1525,32 +1700,53 @@ export class PlanEntitlementService {
1525
1700
  INSERT INTO plan_feature_catalog_overrides (
1526
1701
  tenant_id,
1527
1702
  feature_key,
1703
+ label,
1704
+ description,
1705
+ is_custom,
1528
1706
  public_label,
1529
1707
  public_description,
1708
+ public_description_visible,
1530
1709
  public_display_order,
1531
1710
  created_at,
1532
1711
  updated_at
1533
1712
  )
1534
- VALUES (?, ?, ?, ?, ?, ?, ?)
1713
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1535
1714
  ON CONFLICT (tenant_id, feature_key) DO UPDATE SET
1715
+ label = excluded.label,
1716
+ description = excluded.description,
1717
+ is_custom = excluded.is_custom,
1536
1718
  public_label = excluded.public_label,
1537
1719
  public_description = excluded.public_description,
1720
+ public_description_visible = excluded.public_description_visible,
1538
1721
  public_display_order = excluded.public_display_order,
1539
1722
  updated_at = excluded.updated_at
1540
- `).run(this.tenantId, featureKey, publicLabel, publicDescription, publicDisplayOrder, now, now);
1723
+ `).run(this.tenantId, featureKey, customFeature ? label : null, customFeature ? description : null, customFeature ? 1 : 0, publicLabel, publicDescription, publicDescriptionVisible === null ? null : (publicDescriptionVisible ? 1 : 0), publicDisplayOrder, now, now);
1541
1724
  });
1542
1725
  tx();
1543
- return this.listPlanFeatureCatalog().find((feature) => feature.featureKey === featureKey) || {
1726
+ return this.listPlanFeatureCatalog().find((feature) => feature.featureKey === featureKey) || (customFeature ? {
1727
+ featureKey,
1728
+ label,
1729
+ description,
1730
+ isCustom: true,
1731
+ publicLabel,
1732
+ publicDescription,
1733
+ publicDescriptionVisible,
1734
+ publicDisplayOrder,
1735
+ allowedAccessModes: ['enabled', 'disabled'],
1736
+ configTemplate: {}
1737
+ } : {
1544
1738
  featureKey: catalogFeature.featureKey,
1545
1739
  label: catalogFeature.label,
1546
1740
  description: catalogFeature.description,
1741
+ isCustom: false,
1547
1742
  publicLabel,
1548
1743
  publicDescription,
1744
+ publicDescriptionVisible,
1549
1745
  publicDisplayOrder,
1550
1746
  allowedAccessModes: [...catalogFeature.allowedAccessModes],
1551
1747
  configTemplate: { ...catalogFeature.configTemplate },
1552
1748
  ...(catalogFeature.dependsOn?.length ? { dependsOn: [...catalogFeature.dependsOn] } : {})
1553
- };
1749
+ });
1554
1750
  }
1555
1751
  listPlanVersions(planId) {
1556
1752
  const normalizedPlanId = String(planId || '').trim().toLowerCase();
@@ -17,33 +17,40 @@ describe('TaskforceCore plan feature catalog marketing copy', () => {
17
17
  expect(initialFeature?.label).toBe('AI Profiles');
18
18
  expect(initialFeature?.publicLabel ?? null).toBeNull();
19
19
  expect(initialFeature?.publicDescription ?? null).toBeNull();
20
+ expect(initialFeature?.publicDescriptionVisible ?? null).toBeNull();
20
21
  expect(initialFeature?.publicDisplayOrder).toBe(20);
21
22
  const updated = core.updatePlanFeatureCatalogMarketingCopy({
22
23
  featureKey: 'collaboration.ai_profiles',
23
24
  publicLabel: 'AI teammates',
24
25
  publicDescription: 'Add reusable AI teammates to your workspace.',
26
+ publicDescriptionVisible: false,
25
27
  publicDisplayOrder: 24
26
28
  });
27
29
  expect(updated.publicLabel).toBe('AI teammates');
28
30
  expect(updated.publicDescription).toBe('Add reusable AI teammates to your workspace.');
31
+ expect(updated.publicDescriptionVisible).toBe(false);
29
32
  expect(updated.publicDisplayOrder).toBe(24);
30
33
  const persisted = core.listPlanFeatureCatalog().find((feature) => feature.featureKey === 'collaboration.ai_profiles');
31
34
  expect(persisted?.publicLabel).toBe('AI teammates');
32
35
  expect(persisted?.publicDescription).toBe('Add reusable AI teammates to your workspace.');
36
+ expect(persisted?.publicDescriptionVisible).toBe(false);
33
37
  expect(persisted?.publicDisplayOrder).toBe(24);
34
38
  const cleared = core.updatePlanFeatureCatalogMarketingCopy({
35
39
  featureKey: 'collaboration.ai_profiles',
36
40
  publicLabel: ' ',
37
41
  publicDescription: '',
42
+ publicDescriptionVisible: null,
38
43
  publicDisplayOrder: null
39
44
  });
40
45
  expect(cleared.publicLabel ?? null).toBeNull();
41
46
  expect(cleared.publicDescription ?? null).toBeNull();
47
+ expect(cleared.publicDescriptionVisible ?? null).toBeNull();
42
48
  expect(cleared.publicDisplayOrder).toBe(20);
43
49
  const fallback = core.listPlanFeatureCatalog().find((feature) => feature.featureKey === 'collaboration.ai_profiles');
44
50
  expect(fallback?.label).toBe('AI Profiles');
45
51
  expect(fallback?.publicLabel ?? null).toBeNull();
46
52
  expect(fallback?.publicDescription ?? null).toBeNull();
53
+ expect(fallback?.publicDescriptionVisible ?? null).toBeNull();
47
54
  expect(fallback?.publicDisplayOrder).toBe(20);
48
55
  core.close();
49
56
  fs.rmSync(projectRoot, { recursive: true, force: true });
@@ -81,4 +88,52 @@ describe('TaskforceCore plan feature catalog marketing copy', () => {
81
88
  core.close();
82
89
  fs.rmSync(projectRoot, { recursive: true, force: true });
83
90
  });
91
+ it('creates, updates, and deletes custom feature catalog entries', () => {
92
+ const projectRoot = makeProjectRoot();
93
+ const core = new TaskforceCore({
94
+ projectRoot,
95
+ storagePath: path.join(projectRoot, '.taskforce')
96
+ });
97
+ const created = core.createCustomPlanFeatureCatalogEntry({
98
+ label: 'Unlimited Local Workspaces',
99
+ description: 'Run unlimited local workspaces on your device.',
100
+ publicDescriptionVisible: false,
101
+ publicDisplayOrder: 12
102
+ });
103
+ expect(created.featureKey).toMatch(/^marketing\./);
104
+ expect(created.isCustom).toBe(true);
105
+ expect(created.label).toBe('Unlimited Local Workspaces');
106
+ expect(created.description).toBe('Run unlimited local workspaces on your device.');
107
+ expect(created.publicDescriptionVisible).toBe(false);
108
+ expect(created.publicDisplayOrder).toBe(12);
109
+ const updated = core.updatePlanFeatureCatalogMarketingCopy({
110
+ featureKey: created.featureKey,
111
+ label: 'Unlimited Local Workspaces',
112
+ description: 'Run unlimited local workspaces offline on your device.',
113
+ publicLabel: 'Unlimited Local Workspaces',
114
+ publicDescription: 'Create as many fully local workspaces as you need.',
115
+ publicDescriptionVisible: true,
116
+ publicDisplayOrder: 14
117
+ });
118
+ expect(updated.isCustom).toBe(true);
119
+ expect(updated.description).toBe('Run unlimited local workspaces offline on your device.');
120
+ expect(updated.publicLabel).toBe('Unlimited Local Workspaces');
121
+ expect(updated.publicDescription).toBe('Create as many fully local workspaces as you need.');
122
+ expect(updated.publicDescriptionVisible).toBe(true);
123
+ expect(updated.publicDisplayOrder).toBe(14);
124
+ const plan = core.upsertPlanCatalog({ planId: 'free', displayName: 'Free', status: 'active' });
125
+ expect(plan.planId).toBe('free');
126
+ core.updatePlanVersionFeatures({
127
+ planVersionId: 'free-v1',
128
+ features: [
129
+ { featureKey: created.featureKey, access: 'enabled', config: {} }
130
+ ]
131
+ });
132
+ expect(core.getPlanVersionFeatures('free', 'free-v1').features[created.featureKey]?.access).toBe('enabled');
133
+ core.deleteCustomPlanFeatureCatalogEntry(created.featureKey);
134
+ expect(core.listPlanFeatureCatalog().some((feature) => feature.featureKey === created.featureKey)).toBe(false);
135
+ expect(core.getPlanVersionFeatures('free', 'free-v1').features[created.featureKey]).toBeUndefined();
136
+ core.close();
137
+ fs.rmSync(projectRoot, { recursive: true, force: true });
138
+ });
84
139
  });
@@ -617,6 +617,8 @@ export interface PlanFeatureCatalogItem {
617
617
  featureKey: string;
618
618
  label: string;
619
619
  description: string;
620
+ isCustom?: boolean;
621
+ publicDescriptionVisible?: boolean | null;
620
622
  allowedAccessModes: PlanFeatureAccess[];
621
623
  configTemplate: Record<string, unknown>;
622
624
  dependsOn?: string[];
@@ -1618,10 +1620,22 @@ export declare class TaskforceCore implements TaskforceSyncCapable {
1618
1620
  };
1619
1621
  listPlanCatalog(): import("./types.js").PlanCatalogSnapshot[];
1620
1622
  listPlanFeatureCatalog(): import("./types.js").PlanFeatureCatalogItem[];
1623
+ createCustomPlanFeatureCatalogEntry(input: {
1624
+ label: string;
1625
+ description?: string | null;
1626
+ publicLabel?: string | null;
1627
+ publicDescription?: string | null;
1628
+ publicDescriptionVisible?: boolean | null;
1629
+ publicDisplayOrder?: number | null;
1630
+ }): import("./types.js").PlanFeatureCatalogItem;
1631
+ deleteCustomPlanFeatureCatalogEntry(featureKey: string): void;
1621
1632
  updatePlanFeatureCatalogMarketingCopy(input: {
1622
1633
  featureKey: string;
1634
+ label?: string | null;
1635
+ description?: string | null;
1623
1636
  publicLabel?: string | null;
1624
1637
  publicDescription?: string | null;
1638
+ publicDescriptionVisible?: boolean | null;
1625
1639
  publicDisplayOrder?: number | null;
1626
1640
  }): import("./types.js").PlanFeatureCatalogItem;
1627
1641
  listPlanVersions(planId: string): import("./types.js").PlanVersionSnapshot[];
@@ -6219,6 +6219,12 @@ export class TaskforceCore {
6219
6219
  listPlanFeatureCatalog() {
6220
6220
  return this.planEntitlementService.listPlanFeatureCatalog();
6221
6221
  }
6222
+ createCustomPlanFeatureCatalogEntry(input) {
6223
+ return this.planEntitlementService.createCustomPlanFeatureCatalogEntry(input);
6224
+ }
6225
+ deleteCustomPlanFeatureCatalogEntry(featureKey) {
6226
+ return this.planEntitlementService.deleteCustomPlanFeatureCatalogEntry(featureKey);
6227
+ }
6222
6228
  updatePlanFeatureCatalogMarketingCopy(input) {
6223
6229
  return this.planEntitlementService.updatePlanFeatureCatalogMarketingCopy(input);
6224
6230
  }
@@ -279,6 +279,7 @@ export interface GlobalTaskforceSettings {
279
279
  siteTheme?: 'dark' | 'light';
280
280
  pricingPageTitle?: string;
281
281
  pricingPageDescription?: string;
282
+ pricingPageShowPublicDescriptions?: boolean;
282
283
  };
283
284
  setup?: {
284
285
  mode?: 'core' | 'operations';
@@ -509,8 +510,10 @@ export interface PlanFeatureCatalogItem {
509
510
  featureKey: string;
510
511
  label: string;
511
512
  description: string;
513
+ isCustom?: boolean;
512
514
  publicLabel?: string | null;
513
515
  publicDescription?: string | null;
516
+ publicDescriptionVisible?: boolean | null;
514
517
  publicDisplayOrder?: number | null;
515
518
  allowedAccessModes: PlanFeatureAccess[];
516
519
  configTemplate: Record<string, unknown>;
@@ -476,6 +476,7 @@ export function ensureBillingSchema(db) {
476
476
  tenant_id TEXT NOT NULL DEFAULT 'default',
477
477
  plan_version_id TEXT NOT NULL,
478
478
  billing_interval TEXT NOT NULL,
479
+ pricing_audience TEXT NOT NULL DEFAULT 'public',
479
480
  stripe_price_id TEXT NOT NULL,
480
481
  pricing_type TEXT NOT NULL DEFAULT 'stripe',
481
482
  active BOOLEAN NOT NULL DEFAULT TRUE,
@@ -483,7 +484,7 @@ export function ensureBillingSchema(db) {
483
484
  currency TEXT,
484
485
  created_at TEXT NOT NULL,
485
486
  updated_at TEXT NOT NULL,
486
- PRIMARY KEY (tenant_id, plan_version_id, billing_interval, stripe_price_id)
487
+ PRIMARY KEY (tenant_id, plan_version_id, billing_interval, pricing_audience, stripe_price_id)
487
488
  );
488
489
 
489
490
  CREATE INDEX IF NOT EXISTS idx_user_billing_tenant ON user_billing(tenant_id);
@@ -501,6 +502,7 @@ export function ensureBillingSchema(db) {
501
502
  const hasUnitAmountColumn = columns.some((c) => c.name === 'unit_amount');
502
503
  const hasCurrencyColumn = columns.some((c) => c.name === 'currency');
503
504
  const hasPricingTypeColumn = columns.some((c) => c.name === 'pricing_type');
505
+ const hasPricingAudienceColumn = columns.some((c) => c.name === 'pricing_audience');
504
506
  if (!hasUnitAmountColumn) {
505
507
  db.exec(`ALTER TABLE billing_price_mappings ADD COLUMN unit_amount INTEGER`);
506
508
  }
@@ -510,30 +512,34 @@ export function ensureBillingSchema(db) {
510
512
  if (!hasPricingTypeColumn) {
511
513
  db.exec(`ALTER TABLE billing_price_mappings ADD COLUMN pricing_type TEXT NOT NULL DEFAULT 'stripe'`);
512
514
  }
515
+ if (!hasPricingAudienceColumn) {
516
+ db.exec(`ALTER TABLE billing_price_mappings ADD COLUMN pricing_audience TEXT NOT NULL DEFAULT 'public'`);
517
+ }
513
518
  ensureBillingPriceMappingsConstraints(db);
514
519
  normalizeUserBillingDefaults(db);
515
520
  }
516
521
  function ensureBillingPriceMappingsConstraints(db) {
517
522
  if (db.provider === 'postgres') {
518
523
  db.exec(`
524
+ ALTER TABLE billing_price_mappings ADD COLUMN IF NOT EXISTS pricing_audience TEXT NOT NULL DEFAULT 'public';
525
+ DROP INDEX IF EXISTS idx_billing_price_mappings_active_interval;
519
526
  ALTER TABLE billing_price_mappings DROP CONSTRAINT IF EXISTS billing_price_mappings_pkey;
520
527
  ALTER TABLE billing_price_mappings
521
528
  ADD CONSTRAINT billing_price_mappings_pkey
522
- PRIMARY KEY (tenant_id, plan_version_id, billing_interval, stripe_price_id);
529
+ PRIMARY KEY (tenant_id, plan_version_id, billing_interval, pricing_audience, stripe_price_id);
523
530
  CREATE UNIQUE INDEX IF NOT EXISTS idx_billing_price_mappings_active_interval
524
- ON billing_price_mappings(tenant_id, plan_version_id, billing_interval)
531
+ ON billing_price_mappings(tenant_id, plan_version_id, billing_interval, pricing_audience)
525
532
  WHERE active = TRUE;
526
533
  `);
527
534
  return;
528
535
  }
529
536
  const indexes = db.prepare(`PRAGMA index_list(billing_price_mappings)`).all();
530
537
  const tableInfo = db.prepare(`PRAGMA table_info(billing_price_mappings)`).all();
531
- const hasActiveIntervalIndex = indexes.some((index) => index.name === 'idx_billing_price_mappings_active_interval');
532
538
  const primaryKeyColumns = tableInfo
533
539
  .filter((column) => Number(column.pk || 0) > 0)
534
540
  .sort((a, b) => Number(a.pk || 0) - Number(b.pk || 0))
535
541
  .map((column) => String(column.name || ''));
536
- const needsPrimaryKeyRepair = primaryKeyColumns.join('|') !== 'tenant_id|plan_version_id|billing_interval|stripe_price_id';
542
+ const needsPrimaryKeyRepair = primaryKeyColumns.join('|') !== 'tenant_id|plan_version_id|billing_interval|pricing_audience|stripe_price_id';
537
543
  if (needsPrimaryKeyRepair) {
538
544
  db.exec(`
539
545
  ALTER TABLE billing_price_mappings RENAME TO billing_price_mappings_legacy;
@@ -542,6 +548,7 @@ function ensureBillingPriceMappingsConstraints(db) {
542
548
  tenant_id TEXT NOT NULL DEFAULT 'default',
543
549
  plan_version_id TEXT NOT NULL,
544
550
  billing_interval TEXT NOT NULL,
551
+ pricing_audience TEXT NOT NULL DEFAULT 'public',
545
552
  stripe_price_id TEXT NOT NULL,
546
553
  pricing_type TEXT NOT NULL DEFAULT 'stripe',
547
554
  active BOOLEAN NOT NULL DEFAULT TRUE,
@@ -549,26 +556,25 @@ function ensureBillingPriceMappingsConstraints(db) {
549
556
  currency TEXT,
550
557
  created_at TEXT NOT NULL,
551
558
  updated_at TEXT NOT NULL,
552
- PRIMARY KEY (tenant_id, plan_version_id, billing_interval, stripe_price_id)
559
+ PRIMARY KEY (tenant_id, plan_version_id, billing_interval, pricing_audience, stripe_price_id)
553
560
  );
554
561
 
555
562
  INSERT INTO billing_price_mappings (
556
- tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
563
+ tenant_id, plan_version_id, billing_interval, pricing_audience, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
557
564
  )
558
565
  SELECT
559
- tenant_id, plan_version_id, billing_interval, stripe_price_id, COALESCE(pricing_type, 'stripe'), active, unit_amount, currency, created_at, updated_at
566
+ tenant_id, plan_version_id, billing_interval, COALESCE(pricing_audience, 'public'), stripe_price_id, COALESCE(pricing_type, 'stripe'), active, unit_amount, currency, created_at, updated_at
560
567
  FROM billing_price_mappings_legacy;
561
568
 
562
569
  DROP TABLE billing_price_mappings_legacy;
563
570
  `);
564
571
  }
565
- if (!hasActiveIntervalIndex) {
566
- db.exec(`
567
- CREATE UNIQUE INDEX IF NOT EXISTS idx_billing_price_mappings_active_interval
568
- ON billing_price_mappings(tenant_id, plan_version_id, billing_interval)
569
- WHERE active = 1;
570
- `);
571
- }
572
+ db.exec(`
573
+ DROP INDEX IF EXISTS idx_billing_price_mappings_active_interval;
574
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_billing_price_mappings_active_interval
575
+ ON billing_price_mappings(tenant_id, plan_version_id, billing_interval, pricing_audience)
576
+ WHERE active = 1;
577
+ `);
572
578
  }
573
579
  function normalizeUserBillingDefaults(db) {
574
580
  if (db.provider === 'postgres') {
@@ -691,8 +697,12 @@ export function ensureEntitlementsSchema(db) {
691
697
  CREATE TABLE IF NOT EXISTS plan_feature_catalog_overrides (
692
698
  tenant_id TEXT NOT NULL DEFAULT 'default',
693
699
  feature_key TEXT NOT NULL,
700
+ label TEXT,
701
+ description TEXT,
702
+ is_custom INTEGER NOT NULL DEFAULT 0,
694
703
  public_label TEXT,
695
704
  public_description TEXT,
705
+ public_description_visible INTEGER,
696
706
  public_display_order INTEGER,
697
707
  created_at TEXT NOT NULL,
698
708
  updated_at TEXT NOT NULL,
@@ -754,6 +764,18 @@ export function ensureEntitlementsSchema(db) {
754
764
  db.exec(`ALTER TABLE plan_catalog ADD COLUMN description TEXT`);
755
765
  }
756
766
  const planFeatureCatalogOverrideColumns = db.prepare(`PRAGMA table_info(plan_feature_catalog_overrides)`).all();
767
+ if (!planFeatureCatalogOverrideColumns.some((column) => column.name === 'label')) {
768
+ db.exec(`ALTER TABLE plan_feature_catalog_overrides ADD COLUMN label TEXT`);
769
+ }
770
+ if (!planFeatureCatalogOverrideColumns.some((column) => column.name === 'description')) {
771
+ db.exec(`ALTER TABLE plan_feature_catalog_overrides ADD COLUMN description TEXT`);
772
+ }
773
+ if (!planFeatureCatalogOverrideColumns.some((column) => column.name === 'is_custom')) {
774
+ db.exec(`ALTER TABLE plan_feature_catalog_overrides ADD COLUMN is_custom INTEGER NOT NULL DEFAULT 0`);
775
+ }
776
+ if (!planFeatureCatalogOverrideColumns.some((column) => column.name === 'public_description_visible')) {
777
+ db.exec(`ALTER TABLE plan_feature_catalog_overrides ADD COLUMN public_description_visible INTEGER`);
778
+ }
757
779
  if (!planFeatureCatalogOverrideColumns.some((column) => column.name === 'public_display_order')) {
758
780
  db.exec(`ALTER TABLE plan_feature_catalog_overrides ADD COLUMN public_display_order INTEGER`);
759
781
  }