@i4e/invest4edu-access-core 0.13.0 → 0.14.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.14.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": {
@@ -211,7 +211,93 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
211
211
  }
212
212
  }
213
213
 
214
- return { findSubscription, entitlementFor, consume };
214
+ /**
215
+ * Give a subject the default plan, if there is one and they have none.
216
+ *
217
+ * A distributor who has never bought anything currently resolves to "no subscription", which
218
+ * fails OPEN — so today every distributor silently has unlimited everything. A default plan is
219
+ * how that becomes deliberate: a real package with real limits, assigned automatically, so the
220
+ * system's answer to "what may this person do" stops being "we never decided".
221
+ *
222
+ * ── Idempotent, and safe to call from anywhere ───────────────────────────────────────────────
223
+ * Returns the existing subscription untouched if one is live. That matters because the natural
224
+ * call site is distributor creation, and distributors get created down several paths (self
225
+ * onboarding, admin, draft approval, bulk import) — a provisioner that could double-assign would
226
+ * have to be wired carefully into exactly one of them, which is how coverage gaps happen. This
227
+ * one can be called from all of them, or twice, or after the fact.
228
+ *
229
+ * ── Never fatal ──────────────────────────────────────────────────────────────────────────────
230
+ * Returns null on any failure rather than throwing. Callers create distributors; a subscription
231
+ * that could not be provisioned must never be the reason a distributor does not exist. The gap
232
+ * is recoverable by calling this again — a failed creation is not.
233
+ *
234
+ * @param {Object} p
235
+ * @param {string} p.subjectType "distributor" | "client"
236
+ * @param {string} p.subjectId
237
+ * @param {string} [p.accountId]
238
+ * @param {Date} [p.now]
239
+ * @returns {Promise<Object|null>} the subscription (existing or new), or null
240
+ */
241
+ async function provisionDefault({ subjectType, subjectId, accountId = null, now = new Date() } = {}) {
242
+ try {
243
+ const db = getDb();
244
+ if (!db || !subjectType || !subjectId) return null;
245
+
246
+ const existing = await findSubscription({ subjectType, subjectId });
247
+ if (existing) return existing;
248
+
249
+ // The default plan is a property of the CATALOGUE, not of this code — so which package new
250
+ // distributors get, and for how long, is changed by editing a plan rather than deploying.
251
+ const plan = await db.collection("plan_catalog").findOne({
252
+ default_for: subjectType,
253
+ status: "active",
254
+ });
255
+ if (!plan) return null;
256
+
257
+ const days = Number(plan.default_duration_days) || 0;
258
+ const start = new Date(now);
259
+ const end = new Date(now);
260
+ if (days > 0) end.setDate(end.getDate() + days);
261
+ // No duration means an open-ended default tier; period_end null reads as "does not lapse".
262
+ const periodEnd = days > 0 ? end : null;
263
+
264
+ /**
265
+ * Status is `active`, not `trialing`.
266
+ *
267
+ * This is a real package on real terms, not a taste of a bigger one — and `trialing` would
268
+ * make trial-cycle entitlement rows take precedence, quietly applying terms nobody wrote for
269
+ * this plan. The trial mechanism stays available for plans that actually want it.
270
+ */
271
+ const doc = {
272
+ account_id: accountId ? oid(accountId) : null,
273
+ subject_type: subjectType,
274
+ subject_id: oid(subjectId),
275
+ plan_code: plan.plan_code,
276
+ cycle: plan.default_cycle || "monthly",
277
+ status: "active",
278
+ period_start: start,
279
+ period_end: periodEnd,
280
+ provisioned_default: true, // so a report can tell "given" from "bought"
281
+ createdAt: new Date(),
282
+ };
283
+
284
+ try {
285
+ await db.collection("account_subscriptions").insertOne(doc);
286
+ } catch (e) {
287
+ // Two creation paths racing on the same subject. The partial unique index on a live
288
+ // subscription is what makes this safe: one wins, and the loser reads the winner's row
289
+ // rather than creating a duplicate.
290
+ if (e?.code === 11000) return findSubscription({ subjectType, subjectId });
291
+ throw e;
292
+ }
293
+ return doc;
294
+ } catch (e) {
295
+ logger.warn(`[entitlement] default provisioning failed for ${subjectType} ${subjectId}: ${e.message}`);
296
+ return null;
297
+ }
298
+ }
299
+
300
+ return { findSubscription, entitlementFor, consume, provisionDefault };
215
301
  }
216
302
 
217
303
  export default { createEntitlementStore };