@i4e/invest4edu-access-core 0.16.0 → 0.17.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 +1 -1
- package/src/entitlement-store.d.ts +9 -0
- package/src/entitlement-store.js +59 -1
- package/src/entitlement.js +22 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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": {
|
|
@@ -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
|
@@ -111,6 +111,7 @@ function withOverride(row, overrides = [], featureCode) {
|
|
|
111
111
|
* @param {string} p.featureCode
|
|
112
112
|
* @param {Object} p.feature registry row — `is_meterable` decides if quota applies
|
|
113
113
|
* @param {number} p.used already consumed in this period (caller supplies)
|
|
114
|
+
* @param {number} [p.credits] à-la-carte units bought and not yet spent
|
|
114
115
|
* @param {Object} p.identity { accountId, userId }
|
|
115
116
|
* @param {boolean} [p.bypass] D27 admin break-glass
|
|
116
117
|
* @param {Date} [p.now]
|
|
@@ -119,7 +120,7 @@ function withOverride(row, overrides = [], featureCode) {
|
|
|
119
120
|
*/
|
|
120
121
|
export function resolveEntitlement({
|
|
121
122
|
subscription, entitlementRows = [], featureCode, feature = {},
|
|
122
|
-
used = 0, identity = {}, bypass = false, now = new Date(),
|
|
123
|
+
used = 0, credits = 0, identity = {}, bypass = false, now = new Date(),
|
|
123
124
|
} = {}) {
|
|
124
125
|
const base = {
|
|
125
126
|
quota: null, used, remaining: null, period: null, subject_key: null,
|
|
@@ -163,19 +164,36 @@ export function resolveEntitlement({
|
|
|
163
164
|
const granularity = row.period_granularity || "month";
|
|
164
165
|
const period = periodKey(granularity, { now, subscriptionId: subscription._id, periodStart: subscription.period_start });
|
|
165
166
|
const subject_key = subjectKeyFor(row.quota_scope, identity);
|
|
166
|
-
const
|
|
167
|
+
const planRemaining = Math.max(0, Number(row.quota) - Number(used || 0));
|
|
167
168
|
const withinQuota = Number(used || 0) < Number(row.quota);
|
|
168
169
|
const policy = row.overage_policy || "block";
|
|
169
170
|
|
|
171
|
+
/**
|
|
172
|
+
* À-la-carte credits — units bought one at a time rather than as part of a plan.
|
|
173
|
+
*
|
|
174
|
+
* They top up the plan's allowance rather than replacing it, and are spent ONLY once the plan
|
|
175
|
+
* allowance is gone. That ordering is the whole point: someone who bought ten extra analyses and
|
|
176
|
+
* then renews should not find their purchase quietly consumed by a month they had covered
|
|
177
|
+
* anyway. Plan first, purchase second.
|
|
178
|
+
*
|
|
179
|
+
* Credits do not reset with the period — they were paid for, so they last until used.
|
|
180
|
+
*/
|
|
181
|
+
const creditBalance = Math.max(0, Number(credits || 0));
|
|
182
|
+
const usingCredit = !withinQuota && creditBalance > 0;
|
|
183
|
+
const remaining = planRemaining + creditBalance;
|
|
184
|
+
|
|
170
185
|
return {
|
|
171
186
|
unlocked: true,
|
|
172
187
|
quota: Number(row.quota),
|
|
173
188
|
used: Number(used || 0),
|
|
174
189
|
remaining,
|
|
190
|
+
credits: creditBalance,
|
|
191
|
+
/** Tells consume() which bucket to draw from — the plan's counter, or a purchased credit. */
|
|
192
|
+
usingCredit,
|
|
175
193
|
// Only `block` denies. single_usage/unlimited let the call through — billing catches up after.
|
|
176
|
-
allowed: withinQuota || policy !== "block",
|
|
194
|
+
allowed: withinQuota || usingCredit || policy !== "block",
|
|
177
195
|
overage_policy: policy,
|
|
178
|
-
reason: withinQuota ? R.OK : R.QUOTA_EXCEEDED,
|
|
196
|
+
reason: withinQuota || usingCredit ? R.OK : R.QUOTA_EXCEEDED,
|
|
179
197
|
period,
|
|
180
198
|
subject_key,
|
|
181
199
|
metered: true,
|