@i4e/invest4edu-access-core 0.14.0 → 0.15.1
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.
|
|
3
|
+
"version": "0.15.1",
|
|
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,
|
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);
|
|
@@ -29,7 +29,10 @@ export const LIVE_STATUSES = Object.freeze(["trialing", "active", "past_due"]);
|
|
|
29
29
|
|
|
30
30
|
export const TRANSITIONS = Object.freeze({
|
|
31
31
|
trialing: ["active", "cancelled", "expired"],
|
|
32
|
-
|
|
32
|
+
// `expired` because a FIXED-TERM subscription that reaches its end has simply run out. Without
|
|
33
|
+
// it the only exits are `blocked`, which implies a deliberate cut-off, and `cancelled`, which
|
|
34
|
+
// implies the customer chose to leave — neither is true of a 90-day plan reaching day 91.
|
|
35
|
+
active: ["past_due", "blocked", "cancelled", "expired"],
|
|
33
36
|
past_due: ["active", "blocked", "cancelled"],
|
|
34
37
|
blocked: ["active", "cancelled"],
|
|
35
38
|
cancelled: [],
|