@i4e/invest4edu-access-core 0.32.0 → 0.33.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.
@@ -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 };