@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.13.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": {
@@ -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
- const period = periodKey(useRow.period_granularity || "month", { now, subscriptionId: subId });
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
- return { findSubscription, entitlementFor, consume };
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 };
@@ -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
- if (g === "term") return subscriptionId ? `term:${String(subscriptionId)}` : "lifetime";
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);