@i4e/invest4edu-access-core 0.14.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.14.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,
@@ -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);