@i4e/invest4edu-access-core 0.30.0 → 0.31.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
5
5
  "type": "module",
6
6
  "exports": {
@@ -157,7 +157,28 @@ export function createEntitlementStore({
157
157
  * The per-plan override is your "premium plans give more usage" lever: eCAS can cost 2 on
158
158
  * Discover and 1 on Grow without any per-feature matrix returning through the back door.
159
159
  */
160
- if (creditsMode) {
160
+ /**
161
+ * A per-feature BUDGET opts out of credits mode.
162
+ *
163
+ * `quota` is overloaded, and the two meanings need opposite handling: on VPD it is "12
164
+ * sessions a year" — a budget that must decrement and refuse at zero — while on stock tips it
165
+ * is "5 ideas per answer", a ceiling read on every request and never spent. Credits mode
166
+ * counts only the wallet, so a budget expressed that way silently never moved: Grow showed
167
+ * 12 VPD sessions that could be used forever.
168
+ *
169
+ * So a row with a real number, on a feature that does NOT shape a response, falls through to
170
+ * count mode and is governed by its own counter. Credits stay for what is priced in credits.
171
+ * `shapes_response` is the discriminator because it is a property of the FEATURE — whether it
172
+ * trims an answer or counts a use — not of the plan that sells it.
173
+ */
174
+ const budgetRow = creditsMode ? resolveEntitlementRow(rows, featureCode, now) : null;
175
+ const isOwnBudget = !!budgetRow
176
+ && budgetRow.unlocked !== false
177
+ && feature.shapes_response !== true
178
+ && budgetRow.quota !== null && budgetRow.quota !== undefined && budgetRow.quota !== ""
179
+ && Number.isFinite(Number(budgetRow.quota));
180
+
181
+ if (creditsMode && !isOwnBudget) {
161
182
  const svcRow = resolveEntitlementRow(rows, featureCode, now);
162
183
  // Not named in a credits plan → the same fail-open NOT_IN_PLAN as count mode. A plan
163
184
  // that forgot to include a feature is a config gap, not a paywall.