@i4e/invest4edu-access-core 0.13.0 → 0.15.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-store.js +94 -4
- package/src/entitlement.js +22 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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": {
|
package/src/entitlement-store.js
CHANGED
|
@@ -111,7 +111,7 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
111
111
|
let used = 0;
|
|
112
112
|
const row = rows[0] || null;
|
|
113
113
|
if (feature.is_meterable && row) {
|
|
114
|
-
const period = periodKey(row.period_granularity || "month", { now, subscriptionId: subscription._id });
|
|
114
|
+
const period = periodKey(row.period_granularity || "month", { now, subscriptionId: subscription._id, periodStart: subscription.period_start });
|
|
115
115
|
const subjectKey = subjectKeyFor(row.quota_scope, identity) || "";
|
|
116
116
|
const usage = await db.collection("subscription_usage").findOne({
|
|
117
117
|
subscription_id: subscription._id,
|
|
@@ -131,6 +131,7 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
131
131
|
return {
|
|
132
132
|
...result,
|
|
133
133
|
carriedSubscriptionId: subscription._id,
|
|
134
|
+
carriedPeriodStart: subscription.period_start || null,
|
|
134
135
|
carriedIdentity: identity,
|
|
135
136
|
carriedRow: row,
|
|
136
137
|
};
|
|
@@ -155,7 +156,7 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
155
156
|
*/
|
|
156
157
|
async function consume({
|
|
157
158
|
entitlement, featureCode, n = 1, now = new Date(),
|
|
158
|
-
subscriptionId, row, identity,
|
|
159
|
+
subscriptionId, row, identity, periodStart,
|
|
159
160
|
} = {}) {
|
|
160
161
|
try {
|
|
161
162
|
const db = getDb();
|
|
@@ -166,7 +167,10 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
166
167
|
if (entitlement && !entitlement.metered) return { consumed: false, reason: "not_metered" };
|
|
167
168
|
if (!db || !subId || !useRow) return { consumed: false, reason: "not_metered" };
|
|
168
169
|
|
|
169
|
-
|
|
170
|
+
// The SAME bucket that entitlementFor just checked — recomputing it from different inputs
|
|
171
|
+
// would count against a period nobody validated.
|
|
172
|
+
const start = periodStart !== undefined ? periodStart : entitlement?.carriedPeriodStart;
|
|
173
|
+
const period = periodKey(useRow.period_granularity || "month", { now, subscriptionId: subId, periodStart: start });
|
|
170
174
|
const subjectKey = subjectKeyFor(useRow.quota_scope, ident) || "";
|
|
171
175
|
const filter = {
|
|
172
176
|
subscription_id: subId,
|
|
@@ -211,7 +215,93 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
211
215
|
}
|
|
212
216
|
}
|
|
213
217
|
|
|
214
|
-
|
|
218
|
+
/**
|
|
219
|
+
* Give a subject the default plan, if there is one and they have none.
|
|
220
|
+
*
|
|
221
|
+
* A distributor who has never bought anything currently resolves to "no subscription", which
|
|
222
|
+
* fails OPEN — so today every distributor silently has unlimited everything. A default plan is
|
|
223
|
+
* how that becomes deliberate: a real package with real limits, assigned automatically, so the
|
|
224
|
+
* system's answer to "what may this person do" stops being "we never decided".
|
|
225
|
+
*
|
|
226
|
+
* ── Idempotent, and safe to call from anywhere ───────────────────────────────────────────────
|
|
227
|
+
* Returns the existing subscription untouched if one is live. That matters because the natural
|
|
228
|
+
* call site is distributor creation, and distributors get created down several paths (self
|
|
229
|
+
* onboarding, admin, draft approval, bulk import) — a provisioner that could double-assign would
|
|
230
|
+
* have to be wired carefully into exactly one of them, which is how coverage gaps happen. This
|
|
231
|
+
* one can be called from all of them, or twice, or after the fact.
|
|
232
|
+
*
|
|
233
|
+
* ── Never fatal ──────────────────────────────────────────────────────────────────────────────
|
|
234
|
+
* Returns null on any failure rather than throwing. Callers create distributors; a subscription
|
|
235
|
+
* that could not be provisioned must never be the reason a distributor does not exist. The gap
|
|
236
|
+
* is recoverable by calling this again — a failed creation is not.
|
|
237
|
+
*
|
|
238
|
+
* @param {Object} p
|
|
239
|
+
* @param {string} p.subjectType "distributor" | "client"
|
|
240
|
+
* @param {string} p.subjectId
|
|
241
|
+
* @param {string} [p.accountId]
|
|
242
|
+
* @param {Date} [p.now]
|
|
243
|
+
* @returns {Promise<Object|null>} the subscription (existing or new), or null
|
|
244
|
+
*/
|
|
245
|
+
async function provisionDefault({ subjectType, subjectId, accountId = null, now = new Date() } = {}) {
|
|
246
|
+
try {
|
|
247
|
+
const db = getDb();
|
|
248
|
+
if (!db || !subjectType || !subjectId) return null;
|
|
249
|
+
|
|
250
|
+
const existing = await findSubscription({ subjectType, subjectId });
|
|
251
|
+
if (existing) return existing;
|
|
252
|
+
|
|
253
|
+
// The default plan is a property of the CATALOGUE, not of this code — so which package new
|
|
254
|
+
// distributors get, and for how long, is changed by editing a plan rather than deploying.
|
|
255
|
+
const plan = await db.collection("plan_catalog").findOne({
|
|
256
|
+
default_for: subjectType,
|
|
257
|
+
status: "active",
|
|
258
|
+
});
|
|
259
|
+
if (!plan) return null;
|
|
260
|
+
|
|
261
|
+
const days = Number(plan.default_duration_days) || 0;
|
|
262
|
+
const start = new Date(now);
|
|
263
|
+
const end = new Date(now);
|
|
264
|
+
if (days > 0) end.setDate(end.getDate() + days);
|
|
265
|
+
// No duration means an open-ended default tier; period_end null reads as "does not lapse".
|
|
266
|
+
const periodEnd = days > 0 ? end : null;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Status is `active`, not `trialing`.
|
|
270
|
+
*
|
|
271
|
+
* This is a real package on real terms, not a taste of a bigger one — and `trialing` would
|
|
272
|
+
* make trial-cycle entitlement rows take precedence, quietly applying terms nobody wrote for
|
|
273
|
+
* this plan. The trial mechanism stays available for plans that actually want it.
|
|
274
|
+
*/
|
|
275
|
+
const doc = {
|
|
276
|
+
account_id: accountId ? oid(accountId) : null,
|
|
277
|
+
subject_type: subjectType,
|
|
278
|
+
subject_id: oid(subjectId),
|
|
279
|
+
plan_code: plan.plan_code,
|
|
280
|
+
cycle: plan.default_cycle || "monthly",
|
|
281
|
+
status: "active",
|
|
282
|
+
period_start: start,
|
|
283
|
+
period_end: periodEnd,
|
|
284
|
+
provisioned_default: true, // so a report can tell "given" from "bought"
|
|
285
|
+
createdAt: new Date(),
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
await db.collection("account_subscriptions").insertOne(doc);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
// Two creation paths racing on the same subject. The partial unique index on a live
|
|
292
|
+
// subscription is what makes this safe: one wins, and the loser reads the winner's row
|
|
293
|
+
// rather than creating a duplicate.
|
|
294
|
+
if (e?.code === 11000) return findSubscription({ subjectType, subjectId });
|
|
295
|
+
throw e;
|
|
296
|
+
}
|
|
297
|
+
return doc;
|
|
298
|
+
} catch (e) {
|
|
299
|
+
logger.warn(`[entitlement] default provisioning failed for ${subjectType} ${subjectId}: ${e.message}`);
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { findSubscription, entitlementFor, consume, provisionDefault };
|
|
215
305
|
}
|
|
216
306
|
|
|
217
307
|
export default { createEntitlementStore };
|
package/src/entitlement.js
CHANGED
|
@@ -40,13 +40,32 @@ function partsInZone(date, timeZone = QUOTA_TIMEZONE) {
|
|
|
40
40
|
* @param {Object} [opts]
|
|
41
41
|
* @param {Date} [opts.now]
|
|
42
42
|
* @param {string} [opts.subscriptionId] required for `term`
|
|
43
|
+
* @param {Date|string} [opts.periodStart] the current term's start — rolls the `term` bucket
|
|
43
44
|
* @param {string} [opts.timeZone]
|
|
44
45
|
*/
|
|
45
|
-
export function periodKey(granularity, { now = new Date(), subscriptionId = null, timeZone } = {}) {
|
|
46
|
+
export function periodKey(granularity, { now = new Date(), subscriptionId = null, periodStart = null, timeZone } = {}) {
|
|
46
47
|
const g = PERIOD_GRANULARITIES.includes(granularity) ? granularity : "month";
|
|
47
48
|
if (g === "lifetime") return "lifetime";
|
|
48
49
|
// A term quota with no subscription would silently collapse every firm into one shared bucket.
|
|
49
|
-
|
|
50
|
+
/**
|
|
51
|
+
* A term bucket is one BILLING TERM, not the subscription's whole life.
|
|
52
|
+
*
|
|
53
|
+
* Keying on the subscription alone looks right and is subtly wrong: a subscription document
|
|
54
|
+
* survives its own transitions — trial becomes paid, a plan is downgraded then upgraded, a year
|
|
55
|
+
* renews — all on the same `_id`. So an annual allowance consumed during a trial would come out
|
|
56
|
+
* of the year the customer then paid for. Including where the term STARTED rolls the bucket
|
|
57
|
+
* every time the term does.
|
|
58
|
+
*
|
|
59
|
+
* Without a start date it degrades to the old lifetime-of-subscription key rather than throwing,
|
|
60
|
+
* because a missing date must not silently move everyone into one shared bucket.
|
|
61
|
+
*/
|
|
62
|
+
if (g === "term") {
|
|
63
|
+
if (!subscriptionId) return "lifetime";
|
|
64
|
+
if (!periodStart) return `term:${String(subscriptionId)}`;
|
|
65
|
+
const start = periodStart instanceof Date ? periodStart : new Date(periodStart);
|
|
66
|
+
if (Number.isNaN(start.getTime())) return `term:${String(subscriptionId)}`;
|
|
67
|
+
return `term:${String(subscriptionId)}:${start.toISOString().slice(0, 10)}`;
|
|
68
|
+
}
|
|
50
69
|
|
|
51
70
|
const { y, m, d } = partsInZone(now, timeZone);
|
|
52
71
|
if (g === "year") return y;
|
|
@@ -142,7 +161,7 @@ export function resolveEntitlement({
|
|
|
142
161
|
}
|
|
143
162
|
|
|
144
163
|
const granularity = row.period_granularity || "month";
|
|
145
|
-
const period = periodKey(granularity, { now, subscriptionId: subscription._id });
|
|
164
|
+
const period = periodKey(granularity, { now, subscriptionId: subscription._id, periodStart: subscription.period_start });
|
|
146
165
|
const subject_key = subjectKeyFor(row.quota_scope, identity);
|
|
147
166
|
const remaining = Math.max(0, Number(row.quota) - Number(used || 0));
|
|
148
167
|
const withinQuota = Number(used || 0) < Number(row.quota);
|