@i4e/invest4edu-access-core 0.31.0 → 0.32.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/README.md +126 -86
- package/package.json +56 -53
- package/src/access-config.js +68 -68
- package/src/access-resolver.js +269 -269
- package/src/access-schema.js +174 -174
- package/src/credits.js +128 -128
- package/src/entitlement-schema.js +194 -194
- package/src/entitlement-store.d.ts +99 -99
- package/src/entitlement-store.js +610 -610
- package/src/entitlement.js +300 -300
- package/src/entity-status.js +165 -0
- package/src/grid-schema.js +231 -231
- package/src/index.js +74 -57
- package/src/portal-roles.js +66 -0
- package/src/proration.js +155 -155
- package/src/reportee-tree.js +129 -35
- package/src/role-capabilities.js +93 -93
- package/src/route-features.js +292 -292
- package/src/subscription-lifecycle.js +106 -106
- package/src/tenant-context.js +26 -26
- package/src/tenant-plugin.js +177 -177
- package/src/user-entity-link.js +58 -0
- package/src/visible-when.js +108 -108
package/src/entitlement.js
CHANGED
|
@@ -1,300 +1,300 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Entitlement resolution — @i4e/invest4edu-access-core (P6). Pure: no DB, no Mongoose.
|
|
3
|
-
*
|
|
4
|
-
* RBAC says whether a user MAY do a thing. This says whether the FIRM's plan has it unlocked and
|
|
5
|
-
* whether the allowance is spent. Both must pass (TDD §4.2).
|
|
6
|
-
*
|
|
7
|
-
* The one hard rule (§4.3): entitlement **fails open**. An outage in the entitlement path must
|
|
8
|
-
* never block a feature a customer has paid for — the worst acceptable outcome is that we
|
|
9
|
-
* occasionally fail to charge. `overage_policy: 'block'` is the only branch that denies, and only
|
|
10
|
-
* on a definite quota-exceeded answer.
|
|
11
|
-
*/
|
|
12
|
-
import {
|
|
13
|
-
ENTITLEMENT_REASONS as R,
|
|
14
|
-
QUOTA_TIMEZONE,
|
|
15
|
-
PERIOD_GRANULARITIES,
|
|
16
|
-
} from "./entitlement-schema.js";
|
|
17
|
-
|
|
18
|
-
export { ENTITLEMENT_REASONS } from "./entitlement-schema.js";
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Calendar parts of `date` in QUOTA_TIMEZONE.
|
|
22
|
-
*
|
|
23
|
-
* Deliberately via Intl rather than date arithmetic: an offset constant would be wrong the moment
|
|
24
|
-
* anything changes, and a naive `toISOString()` would put an Indian partner's daily reset at 05:30
|
|
25
|
-
* local — silently costing them the tail of every working day.
|
|
26
|
-
*/
|
|
27
|
-
function partsInZone(date, timeZone = QUOTA_TIMEZONE) {
|
|
28
|
-
const fmt = new Intl.DateTimeFormat("en-CA", {
|
|
29
|
-
timeZone, year: "numeric", month: "2-digit", day: "2-digit",
|
|
30
|
-
});
|
|
31
|
-
const [{ value: y }, , { value: m }, , { value: d }] = fmt.formatToParts(date);
|
|
32
|
-
return { y, m, d };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* The bucket key an allowance is counted in. The granularity lives IN the key, so a reset needs no
|
|
37
|
-
* cron: at midnight the key simply changes and the new bucket starts at zero.
|
|
38
|
-
*
|
|
39
|
-
* @param {string} granularity day | month | year | term | lifetime
|
|
40
|
-
* @param {Object} [opts]
|
|
41
|
-
* @param {Date} [opts.now]
|
|
42
|
-
* @param {string} [opts.subscriptionId] required for `term`
|
|
43
|
-
* @param {Date|string} [opts.periodStart] the current term's start — rolls the `term` bucket
|
|
44
|
-
* @param {string} [opts.timeZone]
|
|
45
|
-
*/
|
|
46
|
-
export function periodKey(granularity, { now = new Date(), subscriptionId = null, periodStart = null, timeZone } = {}) {
|
|
47
|
-
const g = PERIOD_GRANULARITIES.includes(granularity) ? granularity : "month";
|
|
48
|
-
if (g === "lifetime") return "lifetime";
|
|
49
|
-
// A term quota with no subscription would silently collapse every firm into one shared bucket.
|
|
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
|
-
}
|
|
69
|
-
|
|
70
|
-
const { y, m, d } = partsInZone(now, timeZone);
|
|
71
|
-
if (g === "year") return y;
|
|
72
|
-
if (g === "month") return `${y}-${m}`;
|
|
73
|
-
return `${y}-${m}-${d}`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Whose allowance this is: the firm, or the individual seat. */
|
|
77
|
-
export function subjectKeyFor(quotaScope, { accountId, userId } = {}) {
|
|
78
|
-
return String((quotaScope === "user" ? userId : accountId) ?? "");
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Pick the live entitlement row for a feature: greatest `effective_date <= now`, not superseded.
|
|
83
|
-
* Rows are versioned rather than edited so a past invoice stays explicable.
|
|
84
|
-
*/
|
|
85
|
-
export function resolveEntitlementRow(rows = [], featureCode, now = new Date()) {
|
|
86
|
-
return rows
|
|
87
|
-
.filter((r) => r
|
|
88
|
-
&& r.feature_code === featureCode
|
|
89
|
-
&& new Date(r.effective_date) <= now
|
|
90
|
-
&& (!r.superseded_date || new Date(r.superseded_date) > now))
|
|
91
|
-
.sort((a, b) => new Date(b.effective_date) - new Date(a.effective_date))[0] || null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Apply a per-firm override on top of the plan row. Concessions live on the subscription. */
|
|
95
|
-
function withOverride(row, overrides = [], featureCode) {
|
|
96
|
-
/**
|
|
97
|
-
* Two shapes in the wild, and getting this wrong is silent AND total.
|
|
98
|
-
*
|
|
99
|
-
* Everything that WRITES an override — the model, the admin API, the Subscribers view — uses an
|
|
100
|
-
* object map keyed by feature code. This function only understood an array, so `.find` on the
|
|
101
|
-
* object threw, `resolveEntitlement` failed open, and the subscriber stopped being metered
|
|
102
|
-
* altogether. Setting a per-customer allowance therefore disabled that customer's counting —
|
|
103
|
-
* the opposite of the intent, with no error anywhere.
|
|
104
|
-
*
|
|
105
|
-
* Both shapes are accepted now. The map is the canonical one.
|
|
106
|
-
*/
|
|
107
|
-
const o = Array.isArray(overrides)
|
|
108
|
-
? overrides.find((x) => x && x.feature_code === featureCode)
|
|
109
|
-
: (overrides && typeof overrides === "object" ? overrides[featureCode] : null);
|
|
110
|
-
if (!o) return row;
|
|
111
|
-
const merged = { ...row };
|
|
112
|
-
for (const k of ["unlocked", "quota", "period_granularity", "quota_scope", "overage_policy", "single_usage_price"]) {
|
|
113
|
-
if (o[k] !== undefined) merged[k] = o[k];
|
|
114
|
-
}
|
|
115
|
-
return merged;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Decide entitlement for one feature.
|
|
120
|
-
*
|
|
121
|
-
* @param {Object} p
|
|
122
|
-
* @param {Object|null} p.subscription account_subscriptions row
|
|
123
|
-
* @param {Array} p.entitlementRows plan_entitlements rows for this plan+cycle
|
|
124
|
-
* @param {string} p.featureCode
|
|
125
|
-
* @param {Object} p.feature registry row — `is_meterable` decides if quota applies
|
|
126
|
-
* @param {number} p.used already consumed in this period (caller supplies)
|
|
127
|
-
* @param {number} [p.credits] à-la-carte units bought and not yet spent
|
|
128
|
-
* @param {Object} p.identity { accountId, userId }
|
|
129
|
-
* @param {boolean} [p.bypass] D27 admin break-glass
|
|
130
|
-
* @param {Date} [p.now]
|
|
131
|
-
* @returns {Object} { unlocked, quota, used, remaining, allowed, overage_policy, reason,
|
|
132
|
-
* period, subject_key, metered }
|
|
133
|
-
*/
|
|
134
|
-
export function resolveEntitlement({
|
|
135
|
-
subscription, entitlementRows = [], featureCode, feature = {},
|
|
136
|
-
used = 0, credits = 0, identity = {}, bypass = false, now = new Date(),
|
|
137
|
-
/**
|
|
138
|
-
* Units this ONE action costs. 1 for count-mode (used + 1 ≤ quota is exactly the old
|
|
139
|
-
* used < quota), the service's credit cost when a credits-mode plan is paying. Threading it
|
|
140
|
-
* through here is what lets one resolver serve both modes instead of a parallel copy.
|
|
141
|
-
*/
|
|
142
|
-
cost = 1,
|
|
143
|
-
} = {}) {
|
|
144
|
-
const base = {
|
|
145
|
-
quota: null, used, remaining: null, period: null, subject_key: null,
|
|
146
|
-
overage_policy: "unlimited", metered: false,
|
|
147
|
-
/**
|
|
148
|
-
* A CEILING that shapes a response — top-N picks, tips per answer — as opposed to `quota`,
|
|
149
|
-
* which is a budget that decrements. Null when the plan sets none. Present on every result so
|
|
150
|
-
* a caller never has to tell "no ceiling" from "this resolver is too old to send one".
|
|
151
|
-
*/
|
|
152
|
-
limit: null,
|
|
153
|
-
};
|
|
154
|
-
const open = (reason) => ({ ...base, unlocked: true, allowed: true, reason });
|
|
155
|
-
|
|
156
|
-
// D27: the break-glass resolves every gate open, including this one.
|
|
157
|
-
if (bypass) return open(R.ADMIN_BYPASS);
|
|
158
|
-
|
|
159
|
-
// `always_available` exists so login/billing/profile survive a blocked subscription — otherwise a
|
|
160
|
-
// past-due firm could not reach the screen that takes their money.
|
|
161
|
-
if (feature.always_available) return open(R.OK);
|
|
162
|
-
|
|
163
|
-
// No subscription: fail OPEN. Absence of a row is far more likely to be our gap than a customer
|
|
164
|
-
// genuinely owning nothing, and denying would break every un-migrated firm on day one.
|
|
165
|
-
if (!subscription) return open(R.NO_SUBSCRIPTION);
|
|
166
|
-
|
|
167
|
-
if (subscription.status === "blocked" || subscription.status === "expired") {
|
|
168
|
-
return { ...base, unlocked: false, allowed: false, reason: R.SUBSCRIPTION_BLOCKED };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const row = withOverride(
|
|
172
|
-
resolveEntitlementRow(entitlementRows, featureCode, now),
|
|
173
|
-
subscription.entitlement_overrides,
|
|
174
|
-
featureCode,
|
|
175
|
-
);
|
|
176
|
-
// Not named in the plan: OPEN. A plan that forgot to list a feature is a config gap, and P6 must
|
|
177
|
-
// not silently switch features off for everyone the moment it ships.
|
|
178
|
-
if (!row) return open(R.NOT_IN_PLAN);
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Locked: the plan does not carry this feature at all.
|
|
182
|
-
*
|
|
183
|
-
* A locked row is an ACCESS decision, so no allowance gets past it — that is what makes tiering
|
|
184
|
-
* mean anything. If a shared credit balance could unlock a locked feature, the cheap plan plus a
|
|
185
|
-
* top-up would be strictly better than the expensive plan, and the tier would collapse.
|
|
186
|
-
*
|
|
187
|
-
* A unit bought OUTRIGHT is different, and is the deliberate escape hatch: someone who paid for
|
|
188
|
-
* one portfolio analysis has bought that analysis, not access to the feature. `credits` here is
|
|
189
|
-
* always the balance for THIS feature — the caller resolves credits-mode plans down this same
|
|
190
|
-
* path precisely because their shared pool must not reach it — so consulting it cannot leak the
|
|
191
|
-
* pool. Nothing is on sale individually by default, which keeps this dormant until someone
|
|
192
|
-
* prices a service for it.
|
|
193
|
-
*/
|
|
194
|
-
if (row.unlocked === false) {
|
|
195
|
-
const owned = Math.max(0, Number(credits || 0));
|
|
196
|
-
if (owned >= 1) {
|
|
197
|
-
return {
|
|
198
|
-
...base,
|
|
199
|
-
unlocked: true,
|
|
200
|
-
allowed: true,
|
|
201
|
-
credits: owned,
|
|
202
|
-
remaining: owned,
|
|
203
|
-
usingCredit: true,
|
|
204
|
-
metered: true,
|
|
205
|
-
quota: 0,
|
|
206
|
-
used: Number(used || 0),
|
|
207
|
-
overage_policy: row.overage_policy || "block",
|
|
208
|
-
reason: R.OK,
|
|
209
|
-
period: periodKey(row.period_granularity || "month", {
|
|
210
|
-
now, subscriptionId: subscription._id, periodStart: subscription.period_start,
|
|
211
|
-
}),
|
|
212
|
-
subject_key: subjectKeyFor(row.quota_scope, identity),
|
|
213
|
-
cost: 1,
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
return { ...base, unlocked: false, allowed: false, overage_policy: row.overage_policy, reason: R.LOCKED };
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Unlocked but not metered → nothing to count.
|
|
220
|
-
/**
|
|
221
|
-
* Unlocked but not counted.
|
|
222
|
-
*
|
|
223
|
-
* `limit` is carried through even though nothing is metered, because a number on an unmetered
|
|
224
|
-
* row is a CEILING rather than a budget: "your top-picks list shows 5" is read on every request
|
|
225
|
-
* and never decrements. Returning it lets a caller shape its response from the plan instead of
|
|
226
|
-
* hard-coding the shape, which is the difference between a config edit and a deploy.
|
|
227
|
-
*
|
|
228
|
-
* `quota` deliberately stays null here — it means "how much is left", and nothing is being
|
|
229
|
-
* spent. Reusing it for a ceiling would make `remaining` a lie and invite a consume() call that
|
|
230
|
-
* should never happen.
|
|
231
|
-
*/
|
|
232
|
-
if (!feature.is_meterable || row.quota === null || row.quota === undefined) {
|
|
233
|
-
// null/undefined is UNSET, and Number() turns both into 0 — which would read as "show
|
|
234
|
-
// nothing", the exact opposite. Only a real number is a ceiling.
|
|
235
|
-
const raw = row.quota;
|
|
236
|
-
const ceiling = raw === null || raw === undefined || raw === "" ? null : Number(raw);
|
|
237
|
-
return {
|
|
238
|
-
...open(feature.is_meterable ? R.OK : R.NOT_METERED),
|
|
239
|
-
overage_policy: row.overage_policy || "unlimited",
|
|
240
|
-
limit: ceiling !== null && Number.isFinite(ceiling) && ceiling >= 0 ? ceiling : null,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const granularity = row.period_granularity || "month";
|
|
245
|
-
const period = periodKey(granularity, { now, subscriptionId: subscription._id, periodStart: subscription.period_start });
|
|
246
|
-
const subject_key = subjectKeyFor(row.quota_scope, identity);
|
|
247
|
-
/**
|
|
248
|
-
* Zero is a PRICE, not a missing value.
|
|
249
|
-
*
|
|
250
|
-
* "Included, costs nothing" is a thing a credits plan has to be able to say, and the most
|
|
251
|
-
* important thing it says it about is placing an order: metered, unlimited on every plan today,
|
|
252
|
-
* and the last action that should ever be refused for an empty balance. Coercing 0 up to 1
|
|
253
|
-
* would quietly put revenue behind the credit meter.
|
|
254
|
-
*/
|
|
255
|
-
// null and "" are UNSET, and Number() turns both into 0 — which would read as "free" and hand
|
|
256
|
-
// away exactly what this branch exists to protect. Only a real number counts as a price.
|
|
257
|
-
const raw = cost === null || cost === undefined || cost === "" ? 1 : Number(cost);
|
|
258
|
-
const unitCost = Number.isFinite(raw) && raw >= 0 ? raw : 1;
|
|
259
|
-
const planRemaining = Math.max(0, Number(row.quota) - Number(used || 0));
|
|
260
|
-
// Cost-aware: the action is allowed only if the WHOLE cost fits. Refusing at balance < cost is
|
|
261
|
-
// the agreed rule — a balance never goes negative on an in-flight action.
|
|
262
|
-
const withinQuota = Number(used || 0) + unitCost <= Number(row.quota);
|
|
263
|
-
const policy = row.overage_policy || "block";
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* À-la-carte credits — units bought one at a time rather than as part of a plan.
|
|
267
|
-
*
|
|
268
|
-
* They top up the plan's allowance rather than replacing it, and are spent ONLY once the plan
|
|
269
|
-
* allowance is gone. That ordering is the whole point: someone who bought ten extra analyses and
|
|
270
|
-
* then renews should not find their purchase quietly consumed by a month they had covered
|
|
271
|
-
* anyway. Plan first, purchase second.
|
|
272
|
-
*
|
|
273
|
-
* Credits do not reset with the period — they were paid for, so they last until used.
|
|
274
|
-
*/
|
|
275
|
-
const creditBalance = Math.max(0, Number(credits || 0));
|
|
276
|
-
const usingCredit = !withinQuota && creditBalance >= unitCost;
|
|
277
|
-
const remaining = planRemaining + creditBalance;
|
|
278
|
-
|
|
279
|
-
return {
|
|
280
|
-
unlocked: true,
|
|
281
|
-
// Declared even on a metered result: absence must never be mistaken for "no ceiling".
|
|
282
|
-
limit: null,
|
|
283
|
-
quota: Number(row.quota),
|
|
284
|
-
used: Number(used || 0),
|
|
285
|
-
remaining,
|
|
286
|
-
credits: creditBalance,
|
|
287
|
-
/** Tells consume() which bucket to draw from — the plan's counter, or a purchased credit. */
|
|
288
|
-
usingCredit,
|
|
289
|
-
// Only `block` denies. single_usage/unlimited let the call through — billing catches up after.
|
|
290
|
-
allowed: withinQuota || usingCredit || policy !== "block",
|
|
291
|
-
overage_policy: policy,
|
|
292
|
-
reason: withinQuota || usingCredit ? R.OK : R.QUOTA_EXCEEDED,
|
|
293
|
-
period,
|
|
294
|
-
subject_key,
|
|
295
|
-
metered: true,
|
|
296
|
-
cost: unitCost,
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
export default { resolveEntitlement, resolveEntitlementRow, periodKey, subjectKeyFor };
|
|
1
|
+
/**
|
|
2
|
+
* Entitlement resolution — @i4e/invest4edu-access-core (P6). Pure: no DB, no Mongoose.
|
|
3
|
+
*
|
|
4
|
+
* RBAC says whether a user MAY do a thing. This says whether the FIRM's plan has it unlocked and
|
|
5
|
+
* whether the allowance is spent. Both must pass (TDD §4.2).
|
|
6
|
+
*
|
|
7
|
+
* The one hard rule (§4.3): entitlement **fails open**. An outage in the entitlement path must
|
|
8
|
+
* never block a feature a customer has paid for — the worst acceptable outcome is that we
|
|
9
|
+
* occasionally fail to charge. `overage_policy: 'block'` is the only branch that denies, and only
|
|
10
|
+
* on a definite quota-exceeded answer.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
ENTITLEMENT_REASONS as R,
|
|
14
|
+
QUOTA_TIMEZONE,
|
|
15
|
+
PERIOD_GRANULARITIES,
|
|
16
|
+
} from "./entitlement-schema.js";
|
|
17
|
+
|
|
18
|
+
export { ENTITLEMENT_REASONS } from "./entitlement-schema.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Calendar parts of `date` in QUOTA_TIMEZONE.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately via Intl rather than date arithmetic: an offset constant would be wrong the moment
|
|
24
|
+
* anything changes, and a naive `toISOString()` would put an Indian partner's daily reset at 05:30
|
|
25
|
+
* local — silently costing them the tail of every working day.
|
|
26
|
+
*/
|
|
27
|
+
function partsInZone(date, timeZone = QUOTA_TIMEZONE) {
|
|
28
|
+
const fmt = new Intl.DateTimeFormat("en-CA", {
|
|
29
|
+
timeZone, year: "numeric", month: "2-digit", day: "2-digit",
|
|
30
|
+
});
|
|
31
|
+
const [{ value: y }, , { value: m }, , { value: d }] = fmt.formatToParts(date);
|
|
32
|
+
return { y, m, d };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The bucket key an allowance is counted in. The granularity lives IN the key, so a reset needs no
|
|
37
|
+
* cron: at midnight the key simply changes and the new bucket starts at zero.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} granularity day | month | year | term | lifetime
|
|
40
|
+
* @param {Object} [opts]
|
|
41
|
+
* @param {Date} [opts.now]
|
|
42
|
+
* @param {string} [opts.subscriptionId] required for `term`
|
|
43
|
+
* @param {Date|string} [opts.periodStart] the current term's start — rolls the `term` bucket
|
|
44
|
+
* @param {string} [opts.timeZone]
|
|
45
|
+
*/
|
|
46
|
+
export function periodKey(granularity, { now = new Date(), subscriptionId = null, periodStart = null, timeZone } = {}) {
|
|
47
|
+
const g = PERIOD_GRANULARITIES.includes(granularity) ? granularity : "month";
|
|
48
|
+
if (g === "lifetime") return "lifetime";
|
|
49
|
+
// A term quota with no subscription would silently collapse every firm into one shared bucket.
|
|
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
|
+
}
|
|
69
|
+
|
|
70
|
+
const { y, m, d } = partsInZone(now, timeZone);
|
|
71
|
+
if (g === "year") return y;
|
|
72
|
+
if (g === "month") return `${y}-${m}`;
|
|
73
|
+
return `${y}-${m}-${d}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whose allowance this is: the firm, or the individual seat. */
|
|
77
|
+
export function subjectKeyFor(quotaScope, { accountId, userId } = {}) {
|
|
78
|
+
return String((quotaScope === "user" ? userId : accountId) ?? "");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Pick the live entitlement row for a feature: greatest `effective_date <= now`, not superseded.
|
|
83
|
+
* Rows are versioned rather than edited so a past invoice stays explicable.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveEntitlementRow(rows = [], featureCode, now = new Date()) {
|
|
86
|
+
return rows
|
|
87
|
+
.filter((r) => r
|
|
88
|
+
&& r.feature_code === featureCode
|
|
89
|
+
&& new Date(r.effective_date) <= now
|
|
90
|
+
&& (!r.superseded_date || new Date(r.superseded_date) > now))
|
|
91
|
+
.sort((a, b) => new Date(b.effective_date) - new Date(a.effective_date))[0] || null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Apply a per-firm override on top of the plan row. Concessions live on the subscription. */
|
|
95
|
+
function withOverride(row, overrides = [], featureCode) {
|
|
96
|
+
/**
|
|
97
|
+
* Two shapes in the wild, and getting this wrong is silent AND total.
|
|
98
|
+
*
|
|
99
|
+
* Everything that WRITES an override — the model, the admin API, the Subscribers view — uses an
|
|
100
|
+
* object map keyed by feature code. This function only understood an array, so `.find` on the
|
|
101
|
+
* object threw, `resolveEntitlement` failed open, and the subscriber stopped being metered
|
|
102
|
+
* altogether. Setting a per-customer allowance therefore disabled that customer's counting —
|
|
103
|
+
* the opposite of the intent, with no error anywhere.
|
|
104
|
+
*
|
|
105
|
+
* Both shapes are accepted now. The map is the canonical one.
|
|
106
|
+
*/
|
|
107
|
+
const o = Array.isArray(overrides)
|
|
108
|
+
? overrides.find((x) => x && x.feature_code === featureCode)
|
|
109
|
+
: (overrides && typeof overrides === "object" ? overrides[featureCode] : null);
|
|
110
|
+
if (!o) return row;
|
|
111
|
+
const merged = { ...row };
|
|
112
|
+
for (const k of ["unlocked", "quota", "period_granularity", "quota_scope", "overage_policy", "single_usage_price"]) {
|
|
113
|
+
if (o[k] !== undefined) merged[k] = o[k];
|
|
114
|
+
}
|
|
115
|
+
return merged;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Decide entitlement for one feature.
|
|
120
|
+
*
|
|
121
|
+
* @param {Object} p
|
|
122
|
+
* @param {Object|null} p.subscription account_subscriptions row
|
|
123
|
+
* @param {Array} p.entitlementRows plan_entitlements rows for this plan+cycle
|
|
124
|
+
* @param {string} p.featureCode
|
|
125
|
+
* @param {Object} p.feature registry row — `is_meterable` decides if quota applies
|
|
126
|
+
* @param {number} p.used already consumed in this period (caller supplies)
|
|
127
|
+
* @param {number} [p.credits] à-la-carte units bought and not yet spent
|
|
128
|
+
* @param {Object} p.identity { accountId, userId }
|
|
129
|
+
* @param {boolean} [p.bypass] D27 admin break-glass
|
|
130
|
+
* @param {Date} [p.now]
|
|
131
|
+
* @returns {Object} { unlocked, quota, used, remaining, allowed, overage_policy, reason,
|
|
132
|
+
* period, subject_key, metered }
|
|
133
|
+
*/
|
|
134
|
+
export function resolveEntitlement({
|
|
135
|
+
subscription, entitlementRows = [], featureCode, feature = {},
|
|
136
|
+
used = 0, credits = 0, identity = {}, bypass = false, now = new Date(),
|
|
137
|
+
/**
|
|
138
|
+
* Units this ONE action costs. 1 for count-mode (used + 1 ≤ quota is exactly the old
|
|
139
|
+
* used < quota), the service's credit cost when a credits-mode plan is paying. Threading it
|
|
140
|
+
* through here is what lets one resolver serve both modes instead of a parallel copy.
|
|
141
|
+
*/
|
|
142
|
+
cost = 1,
|
|
143
|
+
} = {}) {
|
|
144
|
+
const base = {
|
|
145
|
+
quota: null, used, remaining: null, period: null, subject_key: null,
|
|
146
|
+
overage_policy: "unlimited", metered: false,
|
|
147
|
+
/**
|
|
148
|
+
* A CEILING that shapes a response — top-N picks, tips per answer — as opposed to `quota`,
|
|
149
|
+
* which is a budget that decrements. Null when the plan sets none. Present on every result so
|
|
150
|
+
* a caller never has to tell "no ceiling" from "this resolver is too old to send one".
|
|
151
|
+
*/
|
|
152
|
+
limit: null,
|
|
153
|
+
};
|
|
154
|
+
const open = (reason) => ({ ...base, unlocked: true, allowed: true, reason });
|
|
155
|
+
|
|
156
|
+
// D27: the break-glass resolves every gate open, including this one.
|
|
157
|
+
if (bypass) return open(R.ADMIN_BYPASS);
|
|
158
|
+
|
|
159
|
+
// `always_available` exists so login/billing/profile survive a blocked subscription — otherwise a
|
|
160
|
+
// past-due firm could not reach the screen that takes their money.
|
|
161
|
+
if (feature.always_available) return open(R.OK);
|
|
162
|
+
|
|
163
|
+
// No subscription: fail OPEN. Absence of a row is far more likely to be our gap than a customer
|
|
164
|
+
// genuinely owning nothing, and denying would break every un-migrated firm on day one.
|
|
165
|
+
if (!subscription) return open(R.NO_SUBSCRIPTION);
|
|
166
|
+
|
|
167
|
+
if (subscription.status === "blocked" || subscription.status === "expired") {
|
|
168
|
+
return { ...base, unlocked: false, allowed: false, reason: R.SUBSCRIPTION_BLOCKED };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const row = withOverride(
|
|
172
|
+
resolveEntitlementRow(entitlementRows, featureCode, now),
|
|
173
|
+
subscription.entitlement_overrides,
|
|
174
|
+
featureCode,
|
|
175
|
+
);
|
|
176
|
+
// Not named in the plan: OPEN. A plan that forgot to list a feature is a config gap, and P6 must
|
|
177
|
+
// not silently switch features off for everyone the moment it ships.
|
|
178
|
+
if (!row) return open(R.NOT_IN_PLAN);
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Locked: the plan does not carry this feature at all.
|
|
182
|
+
*
|
|
183
|
+
* A locked row is an ACCESS decision, so no allowance gets past it — that is what makes tiering
|
|
184
|
+
* mean anything. If a shared credit balance could unlock a locked feature, the cheap plan plus a
|
|
185
|
+
* top-up would be strictly better than the expensive plan, and the tier would collapse.
|
|
186
|
+
*
|
|
187
|
+
* A unit bought OUTRIGHT is different, and is the deliberate escape hatch: someone who paid for
|
|
188
|
+
* one portfolio analysis has bought that analysis, not access to the feature. `credits` here is
|
|
189
|
+
* always the balance for THIS feature — the caller resolves credits-mode plans down this same
|
|
190
|
+
* path precisely because their shared pool must not reach it — so consulting it cannot leak the
|
|
191
|
+
* pool. Nothing is on sale individually by default, which keeps this dormant until someone
|
|
192
|
+
* prices a service for it.
|
|
193
|
+
*/
|
|
194
|
+
if (row.unlocked === false) {
|
|
195
|
+
const owned = Math.max(0, Number(credits || 0));
|
|
196
|
+
if (owned >= 1) {
|
|
197
|
+
return {
|
|
198
|
+
...base,
|
|
199
|
+
unlocked: true,
|
|
200
|
+
allowed: true,
|
|
201
|
+
credits: owned,
|
|
202
|
+
remaining: owned,
|
|
203
|
+
usingCredit: true,
|
|
204
|
+
metered: true,
|
|
205
|
+
quota: 0,
|
|
206
|
+
used: Number(used || 0),
|
|
207
|
+
overage_policy: row.overage_policy || "block",
|
|
208
|
+
reason: R.OK,
|
|
209
|
+
period: periodKey(row.period_granularity || "month", {
|
|
210
|
+
now, subscriptionId: subscription._id, periodStart: subscription.period_start,
|
|
211
|
+
}),
|
|
212
|
+
subject_key: subjectKeyFor(row.quota_scope, identity),
|
|
213
|
+
cost: 1,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return { ...base, unlocked: false, allowed: false, overage_policy: row.overage_policy, reason: R.LOCKED };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Unlocked but not metered → nothing to count.
|
|
220
|
+
/**
|
|
221
|
+
* Unlocked but not counted.
|
|
222
|
+
*
|
|
223
|
+
* `limit` is carried through even though nothing is metered, because a number on an unmetered
|
|
224
|
+
* row is a CEILING rather than a budget: "your top-picks list shows 5" is read on every request
|
|
225
|
+
* and never decrements. Returning it lets a caller shape its response from the plan instead of
|
|
226
|
+
* hard-coding the shape, which is the difference between a config edit and a deploy.
|
|
227
|
+
*
|
|
228
|
+
* `quota` deliberately stays null here — it means "how much is left", and nothing is being
|
|
229
|
+
* spent. Reusing it for a ceiling would make `remaining` a lie and invite a consume() call that
|
|
230
|
+
* should never happen.
|
|
231
|
+
*/
|
|
232
|
+
if (!feature.is_meterable || row.quota === null || row.quota === undefined) {
|
|
233
|
+
// null/undefined is UNSET, and Number() turns both into 0 — which would read as "show
|
|
234
|
+
// nothing", the exact opposite. Only a real number is a ceiling.
|
|
235
|
+
const raw = row.quota;
|
|
236
|
+
const ceiling = raw === null || raw === undefined || raw === "" ? null : Number(raw);
|
|
237
|
+
return {
|
|
238
|
+
...open(feature.is_meterable ? R.OK : R.NOT_METERED),
|
|
239
|
+
overage_policy: row.overage_policy || "unlimited",
|
|
240
|
+
limit: ceiling !== null && Number.isFinite(ceiling) && ceiling >= 0 ? ceiling : null,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const granularity = row.period_granularity || "month";
|
|
245
|
+
const period = periodKey(granularity, { now, subscriptionId: subscription._id, periodStart: subscription.period_start });
|
|
246
|
+
const subject_key = subjectKeyFor(row.quota_scope, identity);
|
|
247
|
+
/**
|
|
248
|
+
* Zero is a PRICE, not a missing value.
|
|
249
|
+
*
|
|
250
|
+
* "Included, costs nothing" is a thing a credits plan has to be able to say, and the most
|
|
251
|
+
* important thing it says it about is placing an order: metered, unlimited on every plan today,
|
|
252
|
+
* and the last action that should ever be refused for an empty balance. Coercing 0 up to 1
|
|
253
|
+
* would quietly put revenue behind the credit meter.
|
|
254
|
+
*/
|
|
255
|
+
// null and "" are UNSET, and Number() turns both into 0 — which would read as "free" and hand
|
|
256
|
+
// away exactly what this branch exists to protect. Only a real number counts as a price.
|
|
257
|
+
const raw = cost === null || cost === undefined || cost === "" ? 1 : Number(cost);
|
|
258
|
+
const unitCost = Number.isFinite(raw) && raw >= 0 ? raw : 1;
|
|
259
|
+
const planRemaining = Math.max(0, Number(row.quota) - Number(used || 0));
|
|
260
|
+
// Cost-aware: the action is allowed only if the WHOLE cost fits. Refusing at balance < cost is
|
|
261
|
+
// the agreed rule — a balance never goes negative on an in-flight action.
|
|
262
|
+
const withinQuota = Number(used || 0) + unitCost <= Number(row.quota);
|
|
263
|
+
const policy = row.overage_policy || "block";
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* À-la-carte credits — units bought one at a time rather than as part of a plan.
|
|
267
|
+
*
|
|
268
|
+
* They top up the plan's allowance rather than replacing it, and are spent ONLY once the plan
|
|
269
|
+
* allowance is gone. That ordering is the whole point: someone who bought ten extra analyses and
|
|
270
|
+
* then renews should not find their purchase quietly consumed by a month they had covered
|
|
271
|
+
* anyway. Plan first, purchase second.
|
|
272
|
+
*
|
|
273
|
+
* Credits do not reset with the period — they were paid for, so they last until used.
|
|
274
|
+
*/
|
|
275
|
+
const creditBalance = Math.max(0, Number(credits || 0));
|
|
276
|
+
const usingCredit = !withinQuota && creditBalance >= unitCost;
|
|
277
|
+
const remaining = planRemaining + creditBalance;
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
unlocked: true,
|
|
281
|
+
// Declared even on a metered result: absence must never be mistaken for "no ceiling".
|
|
282
|
+
limit: null,
|
|
283
|
+
quota: Number(row.quota),
|
|
284
|
+
used: Number(used || 0),
|
|
285
|
+
remaining,
|
|
286
|
+
credits: creditBalance,
|
|
287
|
+
/** Tells consume() which bucket to draw from — the plan's counter, or a purchased credit. */
|
|
288
|
+
usingCredit,
|
|
289
|
+
// Only `block` denies. single_usage/unlimited let the call through — billing catches up after.
|
|
290
|
+
allowed: withinQuota || usingCredit || policy !== "block",
|
|
291
|
+
overage_policy: policy,
|
|
292
|
+
reason: withinQuota || usingCredit ? R.OK : R.QUOTA_EXCEEDED,
|
|
293
|
+
period,
|
|
294
|
+
subject_key,
|
|
295
|
+
metered: true,
|
|
296
|
+
cost: unitCost,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export default { resolveEntitlement, resolveEntitlementRow, periodKey, subjectKeyFor };
|