@i4e/invest4edu-access-core 0.21.0 → 0.23.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.21.0",
3
+ "version": "0.23.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": {
@@ -134,9 +134,10 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
134
134
  // Not named in a credits plan → the same fail-open NOT_IN_PLAN as count mode. A plan
135
135
  // that forgot to include a feature is a config gap, not a paywall.
136
136
  if (svcRow && svcRow.unlocked !== false) {
137
- const cost = Number(svcRow.credit_cost) > 0
138
- ? Number(svcRow.credit_cost)
139
- : (Number(feature.credit_cost) > 0 ? Number(feature.credit_cost) : 1);
137
+ // UNSET falls through to the next layer; 0 does not — it is the plan saying "free".
138
+ const priced = (v) => (v === null || v === undefined || v === "" || !Number.isFinite(Number(v)) || Number(v) < 0
139
+ ? null : Number(v));
140
+ const cost = priced(svcRow.credit_cost) ?? priced(feature.credit_cost) ?? 1;
140
141
 
141
142
  let packBalance = 0;
142
143
  try {
@@ -332,7 +333,14 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
332
333
  * here could draw from a bucket nobody checked, at a price nobody quoted.
333
334
  */
334
335
  const bucketCode = entitlement?.carriedFeatureCode || featureCode;
335
- const units = n * (Number(entitlement?.carriedCost) > 0 ? Number(entitlement.carriedCost) : 1);
336
+ const carried = Number(entitlement?.carriedCost);
337
+ const units = n * (Number.isFinite(carried) && carried >= 0 ? carried : 1);
338
+
339
+ /**
340
+ * A free action inside a credits plan: authorised, but there is no balance to move. Writing
341
+ * a zero increment would still create the row and make it look like something was spent.
342
+ */
343
+ if (units === 0) return { consumed: true, free: true };
336
344
 
337
345
  // The SAME bucket that entitlementFor just checked — recomputing it from different inputs
338
346
  // would count against a period nobody validated.
@@ -183,7 +183,18 @@ export function resolveEntitlement({
183
183
  const granularity = row.period_granularity || "month";
184
184
  const period = periodKey(granularity, { now, subscriptionId: subscription._id, periodStart: subscription.period_start });
185
185
  const subject_key = subjectKeyFor(row.quota_scope, identity);
186
- const unitCost = Number(cost) > 0 ? Number(cost) : 1;
186
+ /**
187
+ * Zero is a PRICE, not a missing value.
188
+ *
189
+ * "Included, costs nothing" is a thing a credits plan has to be able to say, and the most
190
+ * important thing it says it about is placing an order: metered, unlimited on every plan today,
191
+ * and the last action that should ever be refused for an empty balance. Coercing 0 up to 1
192
+ * would quietly put revenue behind the credit meter.
193
+ */
194
+ // null and "" are UNSET, and Number() turns both into 0 — which would read as "free" and hand
195
+ // away exactly what this branch exists to protect. Only a real number counts as a price.
196
+ const raw = cost === null || cost === undefined || cost === "" ? 1 : Number(cost);
197
+ const unitCost = Number.isFinite(raw) && raw >= 0 ? raw : 1;
187
198
  const planRemaining = Math.max(0, Number(row.quota) - Number(used || 0));
188
199
  // Cost-aware: the action is allowed only if the WHOLE cost fits. Refusing at balance < cost is
189
200
  // the agreed rule — a balance never goes negative on an in-flight action.
@@ -70,6 +70,12 @@ export const SUBSCRIPTION_EVENT_TYPES = Object.freeze([
70
70
  // Consumption that happened outside the software — a webinar attended, a VPD session held.
71
71
  // There is no request behind it, so this event is the only record of who said it happened.
72
72
  "usage_recorded",
73
+ /**
74
+ * A term closed with credits unspent and the plan's policy carried them forward. Worth its own
75
+ * type because a customer WILL ask why their balance is bigger than their allowance, and the
76
+ * answer — which period it came from, how much, and what the cap did — has to be findable.
77
+ */
78
+ "credits_rolled_over",
73
79
  /**
74
80
  * The refund conversation, recorded as it happens rather than reconstructed afterwards.
75
81
  *