@i4e/invest4edu-access-core 0.16.0 → 0.17.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.16.0",
3
+ "version": "0.17.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": {
@@ -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 = {
@@ -111,6 +111,7 @@ function withOverride(row, overrides = [], featureCode) {
111
111
  * @param {string} p.featureCode
112
112
  * @param {Object} p.feature registry row — `is_meterable` decides if quota applies
113
113
  * @param {number} p.used already consumed in this period (caller supplies)
114
+ * @param {number} [p.credits] à-la-carte units bought and not yet spent
114
115
  * @param {Object} p.identity { accountId, userId }
115
116
  * @param {boolean} [p.bypass] D27 admin break-glass
116
117
  * @param {Date} [p.now]
@@ -119,7 +120,7 @@ function withOverride(row, overrides = [], featureCode) {
119
120
  */
120
121
  export function resolveEntitlement({
121
122
  subscription, entitlementRows = [], featureCode, feature = {},
122
- used = 0, identity = {}, bypass = false, now = new Date(),
123
+ used = 0, credits = 0, identity = {}, bypass = false, now = new Date(),
123
124
  } = {}) {
124
125
  const base = {
125
126
  quota: null, used, remaining: null, period: null, subject_key: null,
@@ -163,19 +164,36 @@ export function resolveEntitlement({
163
164
  const granularity = row.period_granularity || "month";
164
165
  const period = periodKey(granularity, { now, subscriptionId: subscription._id, periodStart: subscription.period_start });
165
166
  const subject_key = subjectKeyFor(row.quota_scope, identity);
166
- const remaining = Math.max(0, Number(row.quota) - Number(used || 0));
167
+ const planRemaining = Math.max(0, Number(row.quota) - Number(used || 0));
167
168
  const withinQuota = Number(used || 0) < Number(row.quota);
168
169
  const policy = row.overage_policy || "block";
169
170
 
171
+ /**
172
+ * À-la-carte credits — units bought one at a time rather than as part of a plan.
173
+ *
174
+ * They top up the plan's allowance rather than replacing it, and are spent ONLY once the plan
175
+ * allowance is gone. That ordering is the whole point: someone who bought ten extra analyses and
176
+ * then renews should not find their purchase quietly consumed by a month they had covered
177
+ * anyway. Plan first, purchase second.
178
+ *
179
+ * Credits do not reset with the period — they were paid for, so they last until used.
180
+ */
181
+ const creditBalance = Math.max(0, Number(credits || 0));
182
+ const usingCredit = !withinQuota && creditBalance > 0;
183
+ const remaining = planRemaining + creditBalance;
184
+
170
185
  return {
171
186
  unlocked: true,
172
187
  quota: Number(row.quota),
173
188
  used: Number(used || 0),
174
189
  remaining,
190
+ credits: creditBalance,
191
+ /** Tells consume() which bucket to draw from — the plan's counter, or a purchased credit. */
192
+ usingCredit,
175
193
  // Only `block` denies. single_usage/unlimited let the call through — billing catches up after.
176
- allowed: withinQuota || policy !== "block",
194
+ allowed: withinQuota || usingCredit || policy !== "block",
177
195
  overage_policy: policy,
178
- reason: withinQuota ? R.OK : R.QUOTA_EXCEEDED,
196
+ reason: withinQuota || usingCredit ? R.OK : R.QUOTA_EXCEEDED,
179
197
  period,
180
198
  subject_key,
181
199
  metered: true,