@i4e/invest4edu-access-core 0.29.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.29.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.
@@ -210,8 +231,28 @@ export function createEntitlementStore({
210
231
  ? { ...creditRows[0], quota: result.quota, overage_policy: result.overage_policy || creditRows[0].overage_policy }
211
232
  : null;
212
233
 
234
+ /**
235
+ * A CEILING survives credits mode.
236
+ *
237
+ * `limit` shapes a response — "your list shows 5" — and is read on every request without
238
+ * ever being spent. A PRICE is what a use costs. They are orthogonal, and the two live on
239
+ * different fields for exactly that reason.
240
+ *
241
+ * Without this line the credits branch returned the wallet's numbers and no `limit` at
242
+ * all, so a consumer reading `ent.limit` (NFD AI's stock-tips shaper does) saw nothing
243
+ * and applied no ceiling. A free plan promising five stock ideas silently served every
244
+ * one of them — the paywall looked configured and was not.
245
+ *
246
+ * Same UNSET-vs-0 care as count mode: null means "no ceiling", 0 means "show nothing".
247
+ */
248
+ const rawCeiling = svcRow.quota;
249
+ const ceiling = rawCeiling === null || rawCeiling === undefined || rawCeiling === ""
250
+ ? null
251
+ : Number(rawCeiling);
252
+
213
253
  return {
214
254
  ...result,
255
+ limit: ceiling !== null && Number.isFinite(ceiling) && ceiling >= 0 ? ceiling : null,
215
256
  mode: "credits",
216
257
  carriedSubjectType: subjectType,
217
258
  carriedSubjectId: subjectId,