@i4e/invest4edu-access-core 0.19.0 → 0.20.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 +1 -1
- package/src/entitlement-schema.js +11 -0
- package/src/entitlement-store.js +161 -17
- package/src/entitlement.js +12 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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": {
|
|
@@ -33,6 +33,16 @@ export const SUBSCRIPTION_STATUSES = Object.freeze([
|
|
|
33
33
|
*/
|
|
34
34
|
export const QUOTA_TIMEZONE = "Asia/Kolkata";
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The synthetic feature that carries a credits-mode plan's allowance.
|
|
38
|
+
*
|
|
39
|
+
* Credits mode needed no new machinery precisely because the allowance is an ordinary
|
|
40
|
+
* entitlement row on this code: it inherits cycle columns, effective-date versioning, trial
|
|
41
|
+
* precedence, as-of resolution and the race-free counter. A plan IS credits-mode when a row for
|
|
42
|
+
* this code resolves — there is no separate flag to drift out of step with the data.
|
|
43
|
+
*/
|
|
44
|
+
export const CREDITS_FEATURE_CODE = "SUBSCRIPTION.CREDITS";
|
|
45
|
+
|
|
36
46
|
/** Reasons an entitlement decision can carry. Stable strings — they surface in APIs and logs. */
|
|
37
47
|
export const ENTITLEMENT_REASONS = Object.freeze({
|
|
38
48
|
OK: "ok",
|
|
@@ -169,6 +179,7 @@ export const METER_CLIENT_DEF = Object.freeze({
|
|
|
169
179
|
|
|
170
180
|
export default {
|
|
171
181
|
PERIOD_GRANULARITIES,
|
|
182
|
+
CREDITS_FEATURE_CODE,
|
|
172
183
|
QUOTA_SCOPES,
|
|
173
184
|
OVERAGE_POLICIES,
|
|
174
185
|
SUBSCRIPTION_STATUSES,
|
package/src/entitlement-store.js
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
* outage into a customer-visible one. Absence is far more likely to be our gap than a customer
|
|
24
24
|
* genuinely owning nothing.
|
|
25
25
|
*/
|
|
26
|
-
import { resolveEntitlement, periodKey, subjectKeyFor } from "./entitlement.js";
|
|
26
|
+
import { resolveEntitlement, resolveEntitlementRow, periodKey, subjectKeyFor } from "./entitlement.js";
|
|
27
|
+
import { CREDITS_FEATURE_CODE } from "./entitlement-schema.js";
|
|
27
28
|
import { LIVE_STATUSES } from "./subscription-lifecycle.js";
|
|
28
29
|
|
|
29
30
|
/** Every failure path returns this, so callers never have to branch on "did the lookup work". */
|
|
@@ -96,15 +97,105 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
96
97
|
const cycles = subscription.status === "trialing"
|
|
97
98
|
? ["trial", subscription.cycle]
|
|
98
99
|
: [subscription.cycle];
|
|
100
|
+
/**
|
|
101
|
+
* Fetched together: the feature's own rows AND the plan's credit-allowance rows. A plan IS
|
|
102
|
+
* credits-mode when a SUBSCRIPTION.CREDITS row resolves — the mode is the data, so there is
|
|
103
|
+
* no flag to drift out of step with it.
|
|
104
|
+
*/
|
|
99
105
|
const fetched = await db.collection("plan_entitlements").find({
|
|
100
106
|
plan_code: subscription.plan_code,
|
|
101
107
|
cycle: { $in: cycles },
|
|
102
|
-
feature_code: featureCode
|
|
108
|
+
feature_code: featureCode === CREDITS_FEATURE_CODE
|
|
109
|
+
? featureCode
|
|
110
|
+
: { $in: [featureCode, CREDITS_FEATURE_CODE] },
|
|
103
111
|
}).toArray();
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
const pick = (code) => {
|
|
113
|
+
const mine = fetched.filter((r) => r.feature_code === code);
|
|
114
|
+
const trial = mine.filter((r) => r.cycle === "trial");
|
|
115
|
+
return subscription.status === "trialing" && trial.length
|
|
116
|
+
? trial
|
|
117
|
+
: mine.filter((r) => r.cycle === subscription.cycle);
|
|
118
|
+
};
|
|
119
|
+
const rows = pick(featureCode);
|
|
120
|
+
const creditRows = featureCode === CREDITS_FEATURE_CODE ? [] : pick(CREDITS_FEATURE_CODE);
|
|
121
|
+
const creditsMode = creditRows.length > 0 && feature.is_meterable;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* CREDITS MODE — the feature's own row says whether it is INCLUDED (and may override the
|
|
125
|
+
* cost); the guard runs against the shared credit allowance, priced per use.
|
|
126
|
+
*
|
|
127
|
+
* cost = plan row's credit_cost > service's registry credit_cost > 1
|
|
128
|
+
*
|
|
129
|
+
* The per-plan override is your "premium plans give more usage" lever: eCAS can cost 2 on
|
|
130
|
+
* Discover and 1 on Grow without any per-feature matrix returning through the back door.
|
|
131
|
+
*/
|
|
132
|
+
if (creditsMode) {
|
|
133
|
+
const svcRow = resolveEntitlementRow(rows, featureCode, now);
|
|
134
|
+
// Not named in a credits plan → the same fail-open NOT_IN_PLAN as count mode. A plan
|
|
135
|
+
// that forgot to include a feature is a config gap, not a paywall.
|
|
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);
|
|
140
|
+
|
|
141
|
+
let packBalance = 0;
|
|
142
|
+
try {
|
|
143
|
+
const bal = await db.collection("feature_credits").aggregate([
|
|
144
|
+
{ $match: {
|
|
145
|
+
subject_type: subjectType,
|
|
146
|
+
subject_id: oid(subjectId),
|
|
147
|
+
feature_code: CREDITS_FEATURE_CODE,
|
|
148
|
+
$expr: { $lt: ["$consumed", "$purchased"] },
|
|
149
|
+
$or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
|
|
150
|
+
} },
|
|
151
|
+
{ $group: { _id: null, n: { $sum: { $subtract: ["$purchased", "$consumed"] } } } },
|
|
152
|
+
]).toArray();
|
|
153
|
+
packBalance = bal[0]?.n || 0;
|
|
154
|
+
} catch (e) {
|
|
155
|
+
logger.warn(`[entitlement] credit-pack balance unreadable: ${e.message}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const creditRow = creditRows[0];
|
|
159
|
+
const period = periodKey(creditRow.period_granularity || "month", {
|
|
160
|
+
now, subscriptionId: subscription._id, periodStart: subscription.period_start,
|
|
161
|
+
});
|
|
162
|
+
const subjectKey = subjectKeyFor(creditRow.quota_scope, identity) || "";
|
|
163
|
+
const usage = await db.collection("subscription_usage").findOne({
|
|
164
|
+
subscription_id: subscription._id,
|
|
165
|
+
feature_code: CREDITS_FEATURE_CODE,
|
|
166
|
+
period,
|
|
167
|
+
subject_key: subjectKey,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const result = await resolveEntitlement({
|
|
171
|
+
subscription,
|
|
172
|
+
entitlementRows: creditRows,
|
|
173
|
+
featureCode: CREDITS_FEATURE_CODE,
|
|
174
|
+
feature: { is_meterable: true },
|
|
175
|
+
used: usage?.used || 0,
|
|
176
|
+
credits: packBalance,
|
|
177
|
+
identity, bypass, now, cost,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const effectiveCreditRow = creditRows[0]
|
|
181
|
+
? { ...creditRows[0], quota: result.quota, overage_policy: result.overage_policy || creditRows[0].overage_policy }
|
|
182
|
+
: null;
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
...result,
|
|
186
|
+
mode: "credits",
|
|
187
|
+
carriedSubjectType: subjectType,
|
|
188
|
+
carriedSubjectId: subjectId,
|
|
189
|
+
carriedSubscriptionId: subscription._id,
|
|
190
|
+
carriedPeriodStart: subscription.period_start || null,
|
|
191
|
+
carriedIdentity: identity,
|
|
192
|
+
carriedRow: effectiveCreditRow,
|
|
193
|
+
// consume() counts against THIS bucket, at THIS price — never recomputed.
|
|
194
|
+
carriedFeatureCode: CREDITS_FEATURE_CODE,
|
|
195
|
+
carriedCost: cost,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
108
199
|
|
|
109
200
|
/**
|
|
110
201
|
* À-la-carte credits — units bought singly. Summed across purchases that still have units
|
|
@@ -184,6 +275,31 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
184
275
|
}
|
|
185
276
|
}
|
|
186
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Per-service breakdown when a shared bucket pays — "where did my 500 credits go".
|
|
280
|
+
*
|
|
281
|
+
* Fire-and-forget and unguarded: it is reporting, not enforcement, and a failed attribution
|
|
282
|
+
* row must never undo a consumption the guard already allowed. Only written when the bucket
|
|
283
|
+
* differs from the feature (credits mode) — in count mode the guarded row IS the attribution.
|
|
284
|
+
*/
|
|
285
|
+
function recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now }) {
|
|
286
|
+
if (!db || bucketCode === featureCode) return;
|
|
287
|
+
try {
|
|
288
|
+
const row = entitlement?.carriedRow || {};
|
|
289
|
+
const period = periodKey(row.period_granularity || "month", {
|
|
290
|
+
now, subscriptionId: subId, periodStart: entitlement?.carriedPeriodStart,
|
|
291
|
+
});
|
|
292
|
+
const subjectKey = subjectKeyFor(row.quota_scope, ident) || "";
|
|
293
|
+
db.collection("subscription_usage").updateOne(
|
|
294
|
+
{ subscription_id: subId, feature_code: featureCode, period, subject_key: subjectKey },
|
|
295
|
+
{ $inc: { used: n, credits_spent: units }, $setOnInsert: { attribution: true } },
|
|
296
|
+
{ upsert: true },
|
|
297
|
+
).catch((e) => logger.warn(`[entitlement] attribution write failed: ${e.message}`));
|
|
298
|
+
} catch (e) {
|
|
299
|
+
logger.warn(`[entitlement] attribution skipped: ${e.message}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
187
303
|
/**
|
|
188
304
|
* Consume one unit — race-free by construction.
|
|
189
305
|
*
|
|
@@ -210,6 +326,14 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
210
326
|
if (entitlement && !entitlement.metered) return { consumed: false, reason: "not_metered" };
|
|
211
327
|
if (!db || !subId || !useRow) return { consumed: false, reason: "not_metered" };
|
|
212
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Credits mode: the BUCKET is the plan's credit allowance, and one action costs `cost`
|
|
331
|
+
* units. Both were decided by the resolve that authorised this call — recomputing either
|
|
332
|
+
* here could draw from a bucket nobody checked, at a price nobody quoted.
|
|
333
|
+
*/
|
|
334
|
+
const bucketCode = entitlement?.carriedFeatureCode || featureCode;
|
|
335
|
+
const units = n * (Number(entitlement?.carriedCost) > 0 ? Number(entitlement.carriedCost) : 1);
|
|
336
|
+
|
|
213
337
|
// The SAME bucket that entitlementFor just checked — recomputing it from different inputs
|
|
214
338
|
// would count against a period nobody validated.
|
|
215
339
|
const start = periodStart !== undefined ? periodStart : entitlement?.carriedPeriodStart;
|
|
@@ -228,14 +352,24 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
228
352
|
{
|
|
229
353
|
subject_type: entitlement.carriedSubjectType,
|
|
230
354
|
subject_id: oid(entitlement.carriedSubjectId),
|
|
231
|
-
feature_code:
|
|
232
|
-
|
|
355
|
+
feature_code: bucketCode,
|
|
356
|
+
/**
|
|
357
|
+
* The WHOLE cost must fit in one pack row, decided atomically. A 2-credit action
|
|
358
|
+
* against a pack with 1 left is refused rather than split across purchases —
|
|
359
|
+
* splitting would need a transaction across rows, and the boundary case (the last
|
|
360
|
+
* unit of one pack) is not worth that machinery. The refusal falls through to the
|
|
361
|
+
* plan counter, which answers honestly.
|
|
362
|
+
*/
|
|
363
|
+
$expr: { $lte: [{ $add: ["$consumed", units] }, "$purchased"] },
|
|
233
364
|
$or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
|
|
234
365
|
},
|
|
235
|
-
{ $inc: { consumed:
|
|
366
|
+
{ $inc: { consumed: units } },
|
|
236
367
|
{ sort: { expires_at: 1, createdAt: 1 } },
|
|
237
368
|
);
|
|
238
|
-
if (hit)
|
|
369
|
+
if (hit) {
|
|
370
|
+
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
371
|
+
return { consumed: true, fromCredit: true };
|
|
372
|
+
}
|
|
239
373
|
// No credit left after all — fall through and let the plan counter answer, which may well
|
|
240
374
|
// refuse. Better than silently succeeding on a balance that just went to zero.
|
|
241
375
|
}
|
|
@@ -244,7 +378,7 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
244
378
|
const subjectKey = subjectKeyFor(useRow.quota_scope, ident) || "";
|
|
245
379
|
const filter = {
|
|
246
380
|
subscription_id: subId,
|
|
247
|
-
feature_code:
|
|
381
|
+
feature_code: bucketCode,
|
|
248
382
|
period,
|
|
249
383
|
subject_key: subjectKey,
|
|
250
384
|
};
|
|
@@ -255,15 +389,21 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
255
389
|
// that record is the evidence for whether a quota is set correctly before anyone is refused.
|
|
256
390
|
if (!capped) {
|
|
257
391
|
await db.collection("subscription_usage").updateOne(
|
|
258
|
-
filter, { $inc: { used:
|
|
392
|
+
filter, { $inc: { used: units }, $setOnInsert: { ...filter } }, { upsert: true },
|
|
259
393
|
);
|
|
394
|
+
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
260
395
|
return { consumed: true };
|
|
261
396
|
}
|
|
262
397
|
|
|
263
|
-
// Capped: try the GUARDED update first, without upsert.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
398
|
+
// Capped: try the GUARDED update first, without upsert. Cost-aware — the whole cost must
|
|
399
|
+
// fit ($lte quota - units is exactly $lt quota when units is 1), and a balance never goes
|
|
400
|
+
// negative on an in-flight action.
|
|
401
|
+
const guarded = { ...filter, used: { $lte: Number(useRow.quota) - units } };
|
|
402
|
+
const hit = await db.collection("subscription_usage").updateOne(guarded, { $inc: { used: units } });
|
|
403
|
+
if (hit.matchedCount > 0) {
|
|
404
|
+
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
405
|
+
return { consumed: true };
|
|
406
|
+
}
|
|
267
407
|
|
|
268
408
|
// No match means either "no row yet" (first use this period) or "quota reached". Distinguish
|
|
269
409
|
// by INSERTING — the unique index makes that safe under concurrency: exactly one racer wins
|
|
@@ -272,7 +412,11 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
272
412
|
// Doing this as an upsert on the guarded filter instead would rely on a duplicate-key
|
|
273
413
|
// EXCEPTION to mean "quota exceeded" — correct by accident, and unreadable.
|
|
274
414
|
try {
|
|
275
|
-
|
|
415
|
+
// First use this period must ALSO fit — an allowance smaller than one action's cost is
|
|
416
|
+
// refused on action one, not discovered at minus-something.
|
|
417
|
+
if (Number(useRow.quota) - units < 0) return { consumed: false, reason: "quota_exceeded" };
|
|
418
|
+
await db.collection("subscription_usage").insertOne({ ...filter, used: units, createdAt: new Date() });
|
|
419
|
+
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
276
420
|
return { consumed: true };
|
|
277
421
|
} catch (e) {
|
|
278
422
|
if (e?.code === 11000) return { consumed: false, reason: "quota_exceeded" };
|
package/src/entitlement.js
CHANGED
|
@@ -134,6 +134,12 @@ function withOverride(row, overrides = [], featureCode) {
|
|
|
134
134
|
export function resolveEntitlement({
|
|
135
135
|
subscription, entitlementRows = [], featureCode, feature = {},
|
|
136
136
|
used = 0, credits = 0, identity = {}, bypass = false, now = new Date(),
|
|
137
|
+
/**
|
|
138
|
+
* Units this ONE action costs. 1 for count-mode (used + 1 ≤ quota is exactly the old
|
|
139
|
+
* used < quota), the service's credit cost when a credits-mode plan is paying. Threading it
|
|
140
|
+
* through here is what lets one resolver serve both modes instead of a parallel copy.
|
|
141
|
+
*/
|
|
142
|
+
cost = 1,
|
|
137
143
|
} = {}) {
|
|
138
144
|
const base = {
|
|
139
145
|
quota: null, used, remaining: null, period: null, subject_key: null,
|
|
@@ -177,8 +183,11 @@ export function resolveEntitlement({
|
|
|
177
183
|
const granularity = row.period_granularity || "month";
|
|
178
184
|
const period = periodKey(granularity, { now, subscriptionId: subscription._id, periodStart: subscription.period_start });
|
|
179
185
|
const subject_key = subjectKeyFor(row.quota_scope, identity);
|
|
186
|
+
const unitCost = Number(cost) > 0 ? Number(cost) : 1;
|
|
180
187
|
const planRemaining = Math.max(0, Number(row.quota) - Number(used || 0));
|
|
181
|
-
|
|
188
|
+
// Cost-aware: the action is allowed only if the WHOLE cost fits. Refusing at balance < cost is
|
|
189
|
+
// the agreed rule — a balance never goes negative on an in-flight action.
|
|
190
|
+
const withinQuota = Number(used || 0) + unitCost <= Number(row.quota);
|
|
182
191
|
const policy = row.overage_policy || "block";
|
|
183
192
|
|
|
184
193
|
/**
|
|
@@ -192,7 +201,7 @@ export function resolveEntitlement({
|
|
|
192
201
|
* Credits do not reset with the period — they were paid for, so they last until used.
|
|
193
202
|
*/
|
|
194
203
|
const creditBalance = Math.max(0, Number(credits || 0));
|
|
195
|
-
const usingCredit = !withinQuota && creditBalance
|
|
204
|
+
const usingCredit = !withinQuota && creditBalance >= unitCost;
|
|
196
205
|
const remaining = planRemaining + creditBalance;
|
|
197
206
|
|
|
198
207
|
return {
|
|
@@ -210,6 +219,7 @@ export function resolveEntitlement({
|
|
|
210
219
|
period,
|
|
211
220
|
subject_key,
|
|
212
221
|
metered: true,
|
|
222
|
+
cost: unitCost,
|
|
213
223
|
};
|
|
214
224
|
}
|
|
215
225
|
|