@i4e/invest4edu-access-core 0.16.0 → 0.17.1

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.16.0",
3
+ "version": "0.17.1",
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": {
@@ -17,13 +17,20 @@ export interface EntitlementResult {
17
17
  metered: boolean;
18
18
  quota: number | null;
19
19
  used: number;
20
+ /** Plan allowance left PLUS unspent à-la-carte credits. */
20
21
  remaining: number | null;
22
+ /** Unspent à-la-carte units bought for this subject and feature. */
23
+ credits?: number;
24
+ /** True when the plan allowance is gone and the next call spends a purchased credit. */
25
+ usingCredit?: boolean;
21
26
  overage_policy: string;
22
27
  reason?: string;
23
28
  period?: string | null;
24
29
  subject_key?: string | null;
25
30
  /** Carried so consume() counts against the exact bucket that was just checked. */
26
31
  carriedSubscriptionId?: unknown;
32
+ carriedSubjectType?: string;
33
+ carriedSubjectId?: string;
27
34
  carriedPeriodStart?: Date | string | null;
28
35
  carriedIdentity?: { accountId?: string | null; userId?: string | null };
29
36
  carriedRow?: Record<string, unknown> | null;
@@ -31,6 +38,8 @@ export interface EntitlementResult {
31
38
 
32
39
  export interface ConsumeResult {
33
40
  consumed: boolean;
41
+ /** The unit came out of a purchased credit rather than the plan's period counter. */
42
+ fromCredit?: boolean;
34
43
  /** "not_metered" | "quota_exceeded" | "error" */
35
44
  reason?: string;
36
45
  }
@@ -106,6 +106,35 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
106
106
  ? trialRows
107
107
  : fetched.filter((r) => r.cycle === subscription.cycle);
108
108
 
109
+ /**
110
+ * À-la-carte credits — units bought singly. Summed across purchases that still have units
111
+ * left and have not expired. Read alongside usage because the resolver needs both to say
112
+ * whether the customer may proceed.
113
+ */
114
+ let credits = 0;
115
+ if (feature.is_meterable || feature.single_usage_purchasable) {
116
+ // Guarded separately: credits are a top-up, and failing to read them must not take the
117
+ // quota check down with it. Losing a purchased unit is bad; silently ceasing to meter
118
+ // everyone because one collection is unreachable is worse.
119
+ try {
120
+ const bal = await db.collection("feature_credits").aggregate([
121
+ {
122
+ $match: {
123
+ subject_type: subjectType,
124
+ subject_id: oid(subjectId),
125
+ feature_code: featureCode,
126
+ $expr: { $lt: ["$consumed", "$purchased"] },
127
+ $or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
128
+ },
129
+ },
130
+ { $group: { _id: null, n: { $sum: { $subtract: ["$purchased", "$consumed"] } } } },
131
+ ]).toArray();
132
+ credits = bal[0]?.n || 0;
133
+ } catch (e) {
134
+ logger.warn(`[entitlement] credit balance unreadable for ${featureCode}: ${e.message}`);
135
+ }
136
+ }
137
+
109
138
  // Only read the counter when something could actually be metered — an unmetered feature
110
139
  // should not cost a query on every request.
111
140
  let used = 0;
@@ -123,13 +152,15 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
123
152
  }
124
153
 
125
154
  const result = await resolveEntitlement({
126
- subscription, entitlementRows: rows, featureCode, feature, used, identity, bypass, now,
155
+ subscription, entitlementRows: rows, featureCode, feature, used, credits, identity, bypass, now,
127
156
  });
128
157
 
129
158
  // Carry what consume() needs, so it cannot recompute the period key and count against a
130
159
  // different bucket than the one that was just checked.
131
160
  return {
132
161
  ...result,
162
+ carriedSubjectType: subjectType,
163
+ carriedSubjectId: subjectId,
133
164
  carriedSubscriptionId: subscription._id,
134
165
  carriedPeriodStart: subscription.period_start || null,
135
166
  carriedIdentity: identity,
@@ -170,6 +201,33 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
170
201
  // The SAME bucket that entitlementFor just checked — recomputing it from different inputs
171
202
  // would count against a period nobody validated.
172
203
  const start = periodStart !== undefined ? periodStart : entitlement?.carriedPeriodStart;
204
+ /**
205
+ * Spending a purchased credit instead of the plan's allowance.
206
+ *
207
+ * `usingCredit` was decided by the same resolve that authorised this call, so the two cannot
208
+ * disagree about which bucket was checked and which is being drawn down. The guard
209
+ * `consumed < purchased` sits INSIDE the filter for the same reason the quota test does:
210
+ * Mongo decides atomically, so two concurrent calls cannot both spend the last unit.
211
+ *
212
+ * Oldest purchase first — a credit with an expiry should be used before one without.
213
+ */
214
+ if (entitlement?.usingCredit) {
215
+ const hit = await db.collection("feature_credits").findOneAndUpdate(
216
+ {
217
+ subject_type: entitlement.carriedSubjectType,
218
+ subject_id: oid(entitlement.carriedSubjectId),
219
+ feature_code: featureCode,
220
+ $expr: { $lt: ["$consumed", "$purchased"] },
221
+ $or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
222
+ },
223
+ { $inc: { consumed: n } },
224
+ { sort: { expires_at: 1, createdAt: 1 } },
225
+ );
226
+ if (hit) return { consumed: true, fromCredit: true };
227
+ // No credit left after all — fall through and let the plan counter answer, which may well
228
+ // refuse. Better than silently succeeding on a balance that just went to zero.
229
+ }
230
+
173
231
  const period = periodKey(useRow.period_granularity || "month", { now, subscriptionId: subId, periodStart: start });
174
232
  const subjectKey = subjectKeyFor(useRow.quota_scope, ident) || "";
175
233
  const filter = {
@@ -93,7 +93,20 @@ export function resolveEntitlementRow(rows = [], featureCode, now = new Date())
93
93
 
94
94
  /** Apply a per-firm override on top of the plan row. Concessions live on the subscription. */
95
95
  function withOverride(row, overrides = [], featureCode) {
96
- const o = (overrides || []).find((x) => x && x.feature_code === featureCode);
96
+ /**
97
+ * Two shapes in the wild, and getting this wrong is silent AND total.
98
+ *
99
+ * Everything that WRITES an override — the model, the admin API, the Subscribers view — uses an
100
+ * object map keyed by feature code. This function only understood an array, so `.find` on the
101
+ * object threw, `resolveEntitlement` failed open, and the subscriber stopped being metered
102
+ * altogether. Setting a per-customer allowance therefore disabled that customer's counting —
103
+ * the opposite of the intent, with no error anywhere.
104
+ *
105
+ * Both shapes are accepted now. The map is the canonical one.
106
+ */
107
+ const o = Array.isArray(overrides)
108
+ ? overrides.find((x) => x && x.feature_code === featureCode)
109
+ : (overrides && typeof overrides === "object" ? overrides[featureCode] : null);
97
110
  if (!o) return row;
98
111
  const merged = { ...row };
99
112
  for (const k of ["unlocked", "quota", "period_granularity", "quota_scope", "overage_policy", "single_usage_price"]) {
@@ -111,6 +124,7 @@ function withOverride(row, overrides = [], featureCode) {
111
124
  * @param {string} p.featureCode
112
125
  * @param {Object} p.feature registry row — `is_meterable` decides if quota applies
113
126
  * @param {number} p.used already consumed in this period (caller supplies)
127
+ * @param {number} [p.credits] à-la-carte units bought and not yet spent
114
128
  * @param {Object} p.identity { accountId, userId }
115
129
  * @param {boolean} [p.bypass] D27 admin break-glass
116
130
  * @param {Date} [p.now]
@@ -119,7 +133,7 @@ function withOverride(row, overrides = [], featureCode) {
119
133
  */
120
134
  export function resolveEntitlement({
121
135
  subscription, entitlementRows = [], featureCode, feature = {},
122
- used = 0, identity = {}, bypass = false, now = new Date(),
136
+ used = 0, credits = 0, identity = {}, bypass = false, now = new Date(),
123
137
  } = {}) {
124
138
  const base = {
125
139
  quota: null, used, remaining: null, period: null, subject_key: null,
@@ -163,19 +177,36 @@ export function resolveEntitlement({
163
177
  const granularity = row.period_granularity || "month";
164
178
  const period = periodKey(granularity, { now, subscriptionId: subscription._id, periodStart: subscription.period_start });
165
179
  const subject_key = subjectKeyFor(row.quota_scope, identity);
166
- const remaining = Math.max(0, Number(row.quota) - Number(used || 0));
180
+ const planRemaining = Math.max(0, Number(row.quota) - Number(used || 0));
167
181
  const withinQuota = Number(used || 0) < Number(row.quota);
168
182
  const policy = row.overage_policy || "block";
169
183
 
184
+ /**
185
+ * À-la-carte credits — units bought one at a time rather than as part of a plan.
186
+ *
187
+ * They top up the plan's allowance rather than replacing it, and are spent ONLY once the plan
188
+ * allowance is gone. That ordering is the whole point: someone who bought ten extra analyses and
189
+ * then renews should not find their purchase quietly consumed by a month they had covered
190
+ * anyway. Plan first, purchase second.
191
+ *
192
+ * Credits do not reset with the period — they were paid for, so they last until used.
193
+ */
194
+ const creditBalance = Math.max(0, Number(credits || 0));
195
+ const usingCredit = !withinQuota && creditBalance > 0;
196
+ const remaining = planRemaining + creditBalance;
197
+
170
198
  return {
171
199
  unlocked: true,
172
200
  quota: Number(row.quota),
173
201
  used: Number(used || 0),
174
202
  remaining,
203
+ credits: creditBalance,
204
+ /** Tells consume() which bucket to draw from — the plan's counter, or a purchased credit. */
205
+ usingCredit,
175
206
  // Only `block` denies. single_usage/unlimited let the call through — billing catches up after.
176
- allowed: withinQuota || policy !== "block",
207
+ allowed: withinQuota || usingCredit || policy !== "block",
177
208
  overage_policy: policy,
178
- reason: withinQuota ? R.OK : R.QUOTA_EXCEEDED,
209
+ reason: withinQuota || usingCredit ? R.OK : R.QUOTA_EXCEEDED,
179
210
  period,
180
211
  subject_key,
181
212
  metered: true,