@i4e/invest4edu-access-core 0.16.0 → 0.17.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 +1 -1
- package/src/entitlement-store.d.ts +9 -0
- package/src/entitlement-store.js +59 -1
- package/src/entitlement.js +36 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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": {
|
|
@@ -17,13 +17,20 @@ export interface EntitlementResult {
|
|
|
17
17
|
metered: boolean;
|
|
18
18
|
quota: number | null;
|
|
19
19
|
used: number;
|
|
20
|
+
/** Plan allowance left PLUS unspent à-la-carte credits. */
|
|
20
21
|
remaining: number | null;
|
|
22
|
+
/** Unspent à-la-carte units bought for this subject and feature. */
|
|
23
|
+
credits?: number;
|
|
24
|
+
/** True when the plan allowance is gone and the next call spends a purchased credit. */
|
|
25
|
+
usingCredit?: boolean;
|
|
21
26
|
overage_policy: string;
|
|
22
27
|
reason?: string;
|
|
23
28
|
period?: string | null;
|
|
24
29
|
subject_key?: string | null;
|
|
25
30
|
/** Carried so consume() counts against the exact bucket that was just checked. */
|
|
26
31
|
carriedSubscriptionId?: unknown;
|
|
32
|
+
carriedSubjectType?: string;
|
|
33
|
+
carriedSubjectId?: string;
|
|
27
34
|
carriedPeriodStart?: Date | string | null;
|
|
28
35
|
carriedIdentity?: { accountId?: string | null; userId?: string | null };
|
|
29
36
|
carriedRow?: Record<string, unknown> | null;
|
|
@@ -31,6 +38,8 @@ export interface EntitlementResult {
|
|
|
31
38
|
|
|
32
39
|
export interface ConsumeResult {
|
|
33
40
|
consumed: boolean;
|
|
41
|
+
/** The unit came out of a purchased credit rather than the plan's period counter. */
|
|
42
|
+
fromCredit?: boolean;
|
|
34
43
|
/** "not_metered" | "quota_exceeded" | "error" */
|
|
35
44
|
reason?: string;
|
|
36
45
|
}
|
package/src/entitlement-store.js
CHANGED
|
@@ -106,6 +106,35 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
106
106
|
? trialRows
|
|
107
107
|
: fetched.filter((r) => r.cycle === subscription.cycle);
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* À-la-carte credits — units bought singly. Summed across purchases that still have units
|
|
111
|
+
* left and have not expired. Read alongside usage because the resolver needs both to say
|
|
112
|
+
* whether the customer may proceed.
|
|
113
|
+
*/
|
|
114
|
+
let credits = 0;
|
|
115
|
+
if (feature.is_meterable || feature.single_usage_purchasable) {
|
|
116
|
+
// Guarded separately: credits are a top-up, and failing to read them must not take the
|
|
117
|
+
// quota check down with it. Losing a purchased unit is bad; silently ceasing to meter
|
|
118
|
+
// everyone because one collection is unreachable is worse.
|
|
119
|
+
try {
|
|
120
|
+
const bal = await db.collection("feature_credits").aggregate([
|
|
121
|
+
{
|
|
122
|
+
$match: {
|
|
123
|
+
subject_type: subjectType,
|
|
124
|
+
subject_id: oid(subjectId),
|
|
125
|
+
feature_code: featureCode,
|
|
126
|
+
$expr: { $lt: ["$consumed", "$purchased"] },
|
|
127
|
+
$or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{ $group: { _id: null, n: { $sum: { $subtract: ["$purchased", "$consumed"] } } } },
|
|
131
|
+
]).toArray();
|
|
132
|
+
credits = bal[0]?.n || 0;
|
|
133
|
+
} catch (e) {
|
|
134
|
+
logger.warn(`[entitlement] credit balance unreadable for ${featureCode}: ${e.message}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
109
138
|
// Only read the counter when something could actually be metered — an unmetered feature
|
|
110
139
|
// should not cost a query on every request.
|
|
111
140
|
let used = 0;
|
|
@@ -123,13 +152,15 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
123
152
|
}
|
|
124
153
|
|
|
125
154
|
const result = await resolveEntitlement({
|
|
126
|
-
subscription, entitlementRows: rows, featureCode, feature, used, identity, bypass, now,
|
|
155
|
+
subscription, entitlementRows: rows, featureCode, feature, used, credits, identity, bypass, now,
|
|
127
156
|
});
|
|
128
157
|
|
|
129
158
|
// Carry what consume() needs, so it cannot recompute the period key and count against a
|
|
130
159
|
// different bucket than the one that was just checked.
|
|
131
160
|
return {
|
|
132
161
|
...result,
|
|
162
|
+
carriedSubjectType: subjectType,
|
|
163
|
+
carriedSubjectId: subjectId,
|
|
133
164
|
carriedSubscriptionId: subscription._id,
|
|
134
165
|
carriedPeriodStart: subscription.period_start || null,
|
|
135
166
|
carriedIdentity: identity,
|
|
@@ -170,6 +201,33 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
170
201
|
// The SAME bucket that entitlementFor just checked — recomputing it from different inputs
|
|
171
202
|
// would count against a period nobody validated.
|
|
172
203
|
const start = periodStart !== undefined ? periodStart : entitlement?.carriedPeriodStart;
|
|
204
|
+
/**
|
|
205
|
+
* Spending a purchased credit instead of the plan's allowance.
|
|
206
|
+
*
|
|
207
|
+
* `usingCredit` was decided by the same resolve that authorised this call, so the two cannot
|
|
208
|
+
* disagree about which bucket was checked and which is being drawn down. The guard
|
|
209
|
+
* `consumed < purchased` sits INSIDE the filter for the same reason the quota test does:
|
|
210
|
+
* Mongo decides atomically, so two concurrent calls cannot both spend the last unit.
|
|
211
|
+
*
|
|
212
|
+
* Oldest purchase first — a credit with an expiry should be used before one without.
|
|
213
|
+
*/
|
|
214
|
+
if (entitlement?.usingCredit) {
|
|
215
|
+
const hit = await db.collection("feature_credits").findOneAndUpdate(
|
|
216
|
+
{
|
|
217
|
+
subject_type: entitlement.carriedSubjectType,
|
|
218
|
+
subject_id: oid(entitlement.carriedSubjectId),
|
|
219
|
+
feature_code: featureCode,
|
|
220
|
+
$expr: { $lt: ["$consumed", "$purchased"] },
|
|
221
|
+
$or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
|
|
222
|
+
},
|
|
223
|
+
{ $inc: { consumed: n } },
|
|
224
|
+
{ sort: { expires_at: 1, createdAt: 1 } },
|
|
225
|
+
);
|
|
226
|
+
if (hit) return { consumed: true, fromCredit: true };
|
|
227
|
+
// No credit left after all — fall through and let the plan counter answer, which may well
|
|
228
|
+
// refuse. Better than silently succeeding on a balance that just went to zero.
|
|
229
|
+
}
|
|
230
|
+
|
|
173
231
|
const period = periodKey(useRow.period_granularity || "month", { now, subscriptionId: subId, periodStart: start });
|
|
174
232
|
const subjectKey = subjectKeyFor(useRow.quota_scope, ident) || "";
|
|
175
233
|
const filter = {
|
package/src/entitlement.js
CHANGED
|
@@ -93,7 +93,20 @@ export function resolveEntitlementRow(rows = [], featureCode, now = new Date())
|
|
|
93
93
|
|
|
94
94
|
/** Apply a per-firm override on top of the plan row. Concessions live on the subscription. */
|
|
95
95
|
function withOverride(row, overrides = [], featureCode) {
|
|
96
|
-
|
|
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);
|
|
97
110
|
if (!o) return row;
|
|
98
111
|
const merged = { ...row };
|
|
99
112
|
for (const k of ["unlocked", "quota", "period_granularity", "quota_scope", "overage_policy", "single_usage_price"]) {
|
|
@@ -111,6 +124,7 @@ function withOverride(row, overrides = [], featureCode) {
|
|
|
111
124
|
* @param {string} p.featureCode
|
|
112
125
|
* @param {Object} p.feature registry row — `is_meterable` decides if quota applies
|
|
113
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
|
|
114
128
|
* @param {Object} p.identity { accountId, userId }
|
|
115
129
|
* @param {boolean} [p.bypass] D27 admin break-glass
|
|
116
130
|
* @param {Date} [p.now]
|
|
@@ -119,7 +133,7 @@ function withOverride(row, overrides = [], featureCode) {
|
|
|
119
133
|
*/
|
|
120
134
|
export function resolveEntitlement({
|
|
121
135
|
subscription, entitlementRows = [], featureCode, feature = {},
|
|
122
|
-
used = 0, identity = {}, bypass = false, now = new Date(),
|
|
136
|
+
used = 0, credits = 0, identity = {}, bypass = false, now = new Date(),
|
|
123
137
|
} = {}) {
|
|
124
138
|
const base = {
|
|
125
139
|
quota: null, used, remaining: null, period: null, subject_key: null,
|
|
@@ -163,19 +177,36 @@ export function resolveEntitlement({
|
|
|
163
177
|
const granularity = row.period_granularity || "month";
|
|
164
178
|
const period = periodKey(granularity, { now, subscriptionId: subscription._id, periodStart: subscription.period_start });
|
|
165
179
|
const subject_key = subjectKeyFor(row.quota_scope, identity);
|
|
166
|
-
const
|
|
180
|
+
const planRemaining = Math.max(0, Number(row.quota) - Number(used || 0));
|
|
167
181
|
const withinQuota = Number(used || 0) < Number(row.quota);
|
|
168
182
|
const policy = row.overage_policy || "block";
|
|
169
183
|
|
|
184
|
+
/**
|
|
185
|
+
* À-la-carte credits — units bought one at a time rather than as part of a plan.
|
|
186
|
+
*
|
|
187
|
+
* They top up the plan's allowance rather than replacing it, and are spent ONLY once the plan
|
|
188
|
+
* allowance is gone. That ordering is the whole point: someone who bought ten extra analyses and
|
|
189
|
+
* then renews should not find their purchase quietly consumed by a month they had covered
|
|
190
|
+
* anyway. Plan first, purchase second.
|
|
191
|
+
*
|
|
192
|
+
* Credits do not reset with the period — they were paid for, so they last until used.
|
|
193
|
+
*/
|
|
194
|
+
const creditBalance = Math.max(0, Number(credits || 0));
|
|
195
|
+
const usingCredit = !withinQuota && creditBalance > 0;
|
|
196
|
+
const remaining = planRemaining + creditBalance;
|
|
197
|
+
|
|
170
198
|
return {
|
|
171
199
|
unlocked: true,
|
|
172
200
|
quota: Number(row.quota),
|
|
173
201
|
used: Number(used || 0),
|
|
174
202
|
remaining,
|
|
203
|
+
credits: creditBalance,
|
|
204
|
+
/** Tells consume() which bucket to draw from — the plan's counter, or a purchased credit. */
|
|
205
|
+
usingCredit,
|
|
175
206
|
// Only `block` denies. single_usage/unlimited let the call through — billing catches up after.
|
|
176
|
-
allowed: withinQuota || policy !== "block",
|
|
207
|
+
allowed: withinQuota || usingCredit || policy !== "block",
|
|
177
208
|
overage_policy: policy,
|
|
178
|
-
reason: withinQuota ? R.OK : R.QUOTA_EXCEEDED,
|
|
209
|
+
reason: withinQuota || usingCredit ? R.OK : R.QUOTA_EXCEEDED,
|
|
179
210
|
period,
|
|
180
211
|
subject_key,
|
|
181
212
|
metered: true,
|