@i4e/invest4edu-access-core 0.6.0 → 0.7.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 +4 -2
- package/src/access-resolver.js +9 -2
- package/src/entitlement-schema.js +183 -0
- package/src/entitlement.js +166 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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": {
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
"./access-config": "./src/access-config.js",
|
|
13
13
|
"./access-schema": "./src/access-schema.js",
|
|
14
14
|
"./access-resolver": "./src/access-resolver.js",
|
|
15
|
-
"./visible-when": "./src/visible-when.js"
|
|
15
|
+
"./visible-when": "./src/visible-when.js",
|
|
16
|
+
"./entitlement": "./src/entitlement.js",
|
|
17
|
+
"./entitlement-schema": "./src/entitlement-schema.js"
|
|
16
18
|
},
|
|
17
19
|
"scripts": {
|
|
18
20
|
"test": "node --test test/"
|
package/src/access-resolver.js
CHANGED
|
@@ -173,9 +173,16 @@ export function resolveAccessSnapshot({
|
|
|
173
173
|
: mergeGrants(grants, { mappedProducts, now });
|
|
174
174
|
|
|
175
175
|
const byCode = new Map(features.map((f) => [f.feature_code, f]));
|
|
176
|
-
|
|
176
|
+
// Product scope comes from the IDENTITY, for admins too. The loader already resolves an
|
|
177
|
+
// unmapped identity (internal staff, platform admins) to every product code, so this is "all"
|
|
178
|
+
// for them without any special case. Deriving a bypass admin's scope from the products declared
|
|
179
|
+
// ON FEATURES — as this used to — yields [] whenever no feature declares one, which is the state
|
|
180
|
+
// of the registry today: a system admin resolved to NO products, and every feature then inherited
|
|
181
|
+
// `products: []`. Kept only as a fallback for the case where identity scope is genuinely empty.
|
|
182
|
+
const identityScope = [...new Set(mappedProducts.map(String))];
|
|
183
|
+
const productScope = bypass && !identityScope.length
|
|
177
184
|
? [...new Set(features.flatMap((f) => f.products || []))]
|
|
178
|
-
:
|
|
185
|
+
: identityScope;
|
|
179
186
|
|
|
180
187
|
const featureMap = {};
|
|
181
188
|
const allowedScreens = [];
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entitlement / subscription schema DEFINITIONS — @i4e/invest4edu-access-core (P6).
|
|
3
|
+
*
|
|
4
|
+
* Plain objects like access-schema.js (D4: no Mongoose in the package). Each backend registers its
|
|
5
|
+
* own models from these, so field names cannot drift between v1 and v2.
|
|
6
|
+
*
|
|
7
|
+
* RBAC answers "may they?"; entitlement answers "is it unlocked, and are they in quota?". Both must
|
|
8
|
+
* pass. See TDD §6, and §6.2a for the 2026-07-29 amendment that generalised quota period + subject
|
|
9
|
+
* and added the service-facing meter.
|
|
10
|
+
*
|
|
11
|
+
* Design: nfd-ui-nextjs/IV-CodingAgent/specs/access-management/TDD-unified-access-and-subscription.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** How often a quota allowance resets. Carried in the period key, so no cron is needed. */
|
|
15
|
+
export const PERIOD_GRANULARITIES = Object.freeze(["day", "month", "year", "term", "lifetime"]);
|
|
16
|
+
|
|
17
|
+
/** Whose allowance is being spent. `account` = per firm (the legacy assumption); `user` = per seat. */
|
|
18
|
+
export const QUOTA_SCOPES = Object.freeze(["account", "user"]);
|
|
19
|
+
|
|
20
|
+
/** What happens when the allowance runs out. */
|
|
21
|
+
export const OVERAGE_POLICIES = Object.freeze(["block", "single_usage", "unlimited"]);
|
|
22
|
+
|
|
23
|
+
export const SUBSCRIPTION_STATUSES = Object.freeze([
|
|
24
|
+
"trialing", "active", "past_due", "blocked", "cancelled", "expired",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The timezone every calendar boundary is evaluated in.
|
|
29
|
+
*
|
|
30
|
+
* A daily quota is meaningless without one: in UTC an Indian partner's "10 a day" would reset at
|
|
31
|
+
* 05:30 local, so they would lose the tail of every working day. Matches the `tz` the MIS pipelines
|
|
32
|
+
* already use.
|
|
33
|
+
*/
|
|
34
|
+
export const QUOTA_TIMEZONE = "Asia/Kolkata";
|
|
35
|
+
|
|
36
|
+
/** Reasons an entitlement decision can carry. Stable strings — they surface in APIs and logs. */
|
|
37
|
+
export const ENTITLEMENT_REASONS = Object.freeze({
|
|
38
|
+
OK: "ok",
|
|
39
|
+
NO_SUBSCRIPTION: "no_subscription",
|
|
40
|
+
NOT_IN_PLAN: "not_in_plan",
|
|
41
|
+
LOCKED: "locked",
|
|
42
|
+
QUOTA_EXCEEDED: "quota_exceeded",
|
|
43
|
+
SUBSCRIPTION_BLOCKED: "subscription_blocked",
|
|
44
|
+
ADMIN_BYPASS: "admin_bypass",
|
|
45
|
+
NOT_METERED: "not_metered",
|
|
46
|
+
ENGINE_OFF: "engine_off",
|
|
47
|
+
RESOLVE_FAILED: "resolve_failed",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Global: the plans on sale. */
|
|
51
|
+
export const PLAN_CATALOG_DEF = Object.freeze({
|
|
52
|
+
collection: "plan_catalog",
|
|
53
|
+
fields: Object.freeze({
|
|
54
|
+
plan_code: { type: "String", required: true, unique: true, index: true, immutable: true },
|
|
55
|
+
name: { type: "String", required: true },
|
|
56
|
+
description: { type: "String" },
|
|
57
|
+
billing_cycle: { type: "String", enum: ["monthly", "quarterly", "annual"], default: "annual" },
|
|
58
|
+
price: { type: "Number", default: 0 },
|
|
59
|
+
currency: { type: "String", default: "INR" },
|
|
60
|
+
position: { type: "Number", default: 0 },
|
|
61
|
+
status: { type: "String", enum: ["active", "dormant", "retired"], default: "active" },
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Global: `(plan, cycle, feature)` → what that plan grants for that feature.
|
|
67
|
+
*
|
|
68
|
+
* VERSIONED by `effective_date`: resolve the row with the greatest `effective_date <= now`. Prices
|
|
69
|
+
* and quotas change, and a past invoice must remain explicable — so rows are superseded, never
|
|
70
|
+
* edited in place.
|
|
71
|
+
*/
|
|
72
|
+
export const PLAN_ENTITLEMENT_DEF = Object.freeze({
|
|
73
|
+
collection: "plan_entitlements",
|
|
74
|
+
fields: Object.freeze({
|
|
75
|
+
plan_code: { type: "String", required: true, index: true },
|
|
76
|
+
billing_cycle: { type: "String", required: true },
|
|
77
|
+
feature_code: { type: "String", required: true, index: true },
|
|
78
|
+
|
|
79
|
+
unlocked: { type: "Boolean", default: true },
|
|
80
|
+
// null = unlimited. 0 is a real value: "in the plan, but no allowance".
|
|
81
|
+
quota: { type: "Number", default: null },
|
|
82
|
+
period_granularity: { type: "String", enum: PERIOD_GRANULARITIES, default: "month" },
|
|
83
|
+
quota_scope: { type: "String", enum: QUOTA_SCOPES, default: "account" },
|
|
84
|
+
overage_policy: { type: "String", enum: OVERAGE_POLICIES, default: "block" },
|
|
85
|
+
single_usage_price: { type: "Number", default: null },
|
|
86
|
+
|
|
87
|
+
effective_date: { type: "Date", required: true, index: true },
|
|
88
|
+
superseded_date: { type: "Date", default: null },
|
|
89
|
+
}),
|
|
90
|
+
indexes: Object.freeze([
|
|
91
|
+
{ keys: { plan_code: 1, billing_cycle: 1, feature_code: 1, effective_date: -1 } },
|
|
92
|
+
]),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** Tenant: one subscription per firm. Replaces AccountPlan + distributor.selected_plan. */
|
|
96
|
+
export const ACCOUNT_SUBSCRIPTION_DEF = Object.freeze({
|
|
97
|
+
collection: "account_subscriptions",
|
|
98
|
+
fields: Object.freeze({
|
|
99
|
+
account_id: { type: "ObjectId", required: true, index: true },
|
|
100
|
+
plan_code: { type: "String", required: true },
|
|
101
|
+
billing_cycle: { type: "String", required: true },
|
|
102
|
+
status: { type: "String", enum: SUBSCRIPTION_STATUSES, default: "trialing", index: true },
|
|
103
|
+
start_date: { type: "Date", required: true },
|
|
104
|
+
end_date: { type: "Date", default: null },
|
|
105
|
+
is_trial: { type: "Boolean", default: false },
|
|
106
|
+
trial_ends_at: { type: "Date", default: null },
|
|
107
|
+
// Per-firm overrides of the plan row: [{ feature_code, quota, unlocked, … }]. Sales concessions
|
|
108
|
+
// belong here, NOT as an edit to the shared plan row.
|
|
109
|
+
entitlement_overrides: { type: "[Mixed]", default: [] },
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Tenant: the quota counters. MUST live in the app DB — `consume` has to be atomic with the read,
|
|
115
|
+
* so this cannot be a side/analytics store.
|
|
116
|
+
*
|
|
117
|
+
* `subject_key` is the account id or the user id depending on `quota_scope`, so one collection
|
|
118
|
+
* serves both without a second shape.
|
|
119
|
+
*/
|
|
120
|
+
export const SUBSCRIPTION_USAGE_DEF = Object.freeze({
|
|
121
|
+
collection: "subscription_usage",
|
|
122
|
+
fields: Object.freeze({
|
|
123
|
+
subscription_id: { type: "ObjectId", required: true, index: true },
|
|
124
|
+
subject_key: { type: "String", required: true },
|
|
125
|
+
feature_code: { type: "String", required: true },
|
|
126
|
+
period: { type: "String", required: true },
|
|
127
|
+
used: { type: "Number", default: 0 },
|
|
128
|
+
last_consumed_at: { type: "Date", default: null },
|
|
129
|
+
}),
|
|
130
|
+
indexes: Object.freeze([
|
|
131
|
+
{ keys: { subscription_id: 1, subject_key: 1, feature_code: 1, period: 1 }, options: { unique: true } },
|
|
132
|
+
]),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Tenant: the exactly-once ledger for `consume`.
|
|
137
|
+
*
|
|
138
|
+
* A tool that times out and retries must not charge the partner twice, so every consume carries an
|
|
139
|
+
* idempotency key and the unique index — not application logic — is what enforces it.
|
|
140
|
+
*/
|
|
141
|
+
export const USAGE_IDEMPOTENCY_DEF = Object.freeze({
|
|
142
|
+
collection: "usage_idempotency",
|
|
143
|
+
fields: Object.freeze({
|
|
144
|
+
idempotency_key: { type: "String", required: true, unique: true, index: true },
|
|
145
|
+
account_id: { type: "ObjectId", index: true },
|
|
146
|
+
feature_code: { type: "String", required: true },
|
|
147
|
+
subject_key: { type: "String" },
|
|
148
|
+
period: { type: "String" },
|
|
149
|
+
n: { type: "Number", default: 1 },
|
|
150
|
+
// The decision returned the first time, replayed verbatim on a duplicate key.
|
|
151
|
+
result: { type: "Mixed", default: null },
|
|
152
|
+
created_date: { type: "Date", default: "now" },
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/** Global: the service callers allowed to meter, and which features each may meter. */
|
|
157
|
+
export const METER_CLIENT_DEF = Object.freeze({
|
|
158
|
+
collection: "meter_clients",
|
|
159
|
+
fields: Object.freeze({
|
|
160
|
+
client_code: { type: "String", required: true, unique: true, index: true },
|
|
161
|
+
name: { type: "String", required: true },
|
|
162
|
+
// Key Vault SECRET NAME, never the secret. An admin API must never be able to read it back.
|
|
163
|
+
key_vault_ref: { type: "String", required: true },
|
|
164
|
+
// A tool may only meter what it is registered for — a leaked NFD-AI key cannot spend PFA quota.
|
|
165
|
+
allowed_features: { type: "[String]", default: [] },
|
|
166
|
+
status: { type: "String", enum: ["active", "disabled"], default: "active" },
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
export default {
|
|
171
|
+
PERIOD_GRANULARITIES,
|
|
172
|
+
QUOTA_SCOPES,
|
|
173
|
+
OVERAGE_POLICIES,
|
|
174
|
+
SUBSCRIPTION_STATUSES,
|
|
175
|
+
QUOTA_TIMEZONE,
|
|
176
|
+
ENTITLEMENT_REASONS,
|
|
177
|
+
PLAN_CATALOG_DEF,
|
|
178
|
+
PLAN_ENTITLEMENT_DEF,
|
|
179
|
+
ACCOUNT_SUBSCRIPTION_DEF,
|
|
180
|
+
SUBSCRIPTION_USAGE_DEF,
|
|
181
|
+
USAGE_IDEMPOTENCY_DEF,
|
|
182
|
+
METER_CLIENT_DEF,
|
|
183
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
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 {string} [opts.timeZone]
|
|
44
|
+
*/
|
|
45
|
+
export function periodKey(granularity, { now = new Date(), subscriptionId = null, timeZone } = {}) {
|
|
46
|
+
const g = PERIOD_GRANULARITIES.includes(granularity) ? granularity : "month";
|
|
47
|
+
if (g === "lifetime") return "lifetime";
|
|
48
|
+
// A term quota with no subscription would silently collapse every firm into one shared bucket.
|
|
49
|
+
if (g === "term") return subscriptionId ? `term:${String(subscriptionId)}` : "lifetime";
|
|
50
|
+
|
|
51
|
+
const { y, m, d } = partsInZone(now, timeZone);
|
|
52
|
+
if (g === "year") return y;
|
|
53
|
+
if (g === "month") return `${y}-${m}`;
|
|
54
|
+
return `${y}-${m}-${d}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whose allowance this is: the firm, or the individual seat. */
|
|
58
|
+
export function subjectKeyFor(quotaScope, { accountId, userId } = {}) {
|
|
59
|
+
return String((quotaScope === "user" ? userId : accountId) ?? "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pick the live entitlement row for a feature: greatest `effective_date <= now`, not superseded.
|
|
64
|
+
* Rows are versioned rather than edited so a past invoice stays explicable.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveEntitlementRow(rows = [], featureCode, now = new Date()) {
|
|
67
|
+
return rows
|
|
68
|
+
.filter((r) => r
|
|
69
|
+
&& r.feature_code === featureCode
|
|
70
|
+
&& new Date(r.effective_date) <= now
|
|
71
|
+
&& (!r.superseded_date || new Date(r.superseded_date) > now))
|
|
72
|
+
.sort((a, b) => new Date(b.effective_date) - new Date(a.effective_date))[0] || null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Apply a per-firm override on top of the plan row. Concessions live on the subscription. */
|
|
76
|
+
function withOverride(row, overrides = [], featureCode) {
|
|
77
|
+
const o = (overrides || []).find((x) => x && x.feature_code === featureCode);
|
|
78
|
+
if (!o) return row;
|
|
79
|
+
const merged = { ...row };
|
|
80
|
+
for (const k of ["unlocked", "quota", "period_granularity", "quota_scope", "overage_policy", "single_usage_price"]) {
|
|
81
|
+
if (o[k] !== undefined) merged[k] = o[k];
|
|
82
|
+
}
|
|
83
|
+
return merged;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Decide entitlement for one feature.
|
|
88
|
+
*
|
|
89
|
+
* @param {Object} p
|
|
90
|
+
* @param {Object|null} p.subscription account_subscriptions row
|
|
91
|
+
* @param {Array} p.entitlementRows plan_entitlements rows for this plan+cycle
|
|
92
|
+
* @param {string} p.featureCode
|
|
93
|
+
* @param {Object} p.feature registry row — `is_meterable` decides if quota applies
|
|
94
|
+
* @param {number} p.used already consumed in this period (caller supplies)
|
|
95
|
+
* @param {Object} p.identity { accountId, userId }
|
|
96
|
+
* @param {boolean} [p.bypass] D27 admin break-glass
|
|
97
|
+
* @param {Date} [p.now]
|
|
98
|
+
* @returns {Object} { unlocked, quota, used, remaining, allowed, overage_policy, reason,
|
|
99
|
+
* period, subject_key, metered }
|
|
100
|
+
*/
|
|
101
|
+
export function resolveEntitlement({
|
|
102
|
+
subscription, entitlementRows = [], featureCode, feature = {},
|
|
103
|
+
used = 0, identity = {}, bypass = false, now = new Date(),
|
|
104
|
+
} = {}) {
|
|
105
|
+
const base = {
|
|
106
|
+
quota: null, used, remaining: null, period: null, subject_key: null,
|
|
107
|
+
overage_policy: "unlimited", metered: false,
|
|
108
|
+
};
|
|
109
|
+
const open = (reason) => ({ ...base, unlocked: true, allowed: true, reason });
|
|
110
|
+
|
|
111
|
+
// D27: the break-glass resolves every gate open, including this one.
|
|
112
|
+
if (bypass) return open(R.ADMIN_BYPASS);
|
|
113
|
+
|
|
114
|
+
// `always_available` exists so login/billing/profile survive a blocked subscription — otherwise a
|
|
115
|
+
// past-due firm could not reach the screen that takes their money.
|
|
116
|
+
if (feature.always_available) return open(R.OK);
|
|
117
|
+
|
|
118
|
+
// No subscription: fail OPEN. Absence of a row is far more likely to be our gap than a customer
|
|
119
|
+
// genuinely owning nothing, and denying would break every un-migrated firm on day one.
|
|
120
|
+
if (!subscription) return open(R.NO_SUBSCRIPTION);
|
|
121
|
+
|
|
122
|
+
if (subscription.status === "blocked" || subscription.status === "expired") {
|
|
123
|
+
return { ...base, unlocked: false, allowed: false, reason: R.SUBSCRIPTION_BLOCKED };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const row = withOverride(
|
|
127
|
+
resolveEntitlementRow(entitlementRows, featureCode, now),
|
|
128
|
+
subscription.entitlement_overrides,
|
|
129
|
+
featureCode,
|
|
130
|
+
);
|
|
131
|
+
// Not named in the plan: OPEN. A plan that forgot to list a feature is a config gap, and P6 must
|
|
132
|
+
// not silently switch features off for everyone the moment it ships.
|
|
133
|
+
if (!row) return open(R.NOT_IN_PLAN);
|
|
134
|
+
|
|
135
|
+
if (row.unlocked === false) {
|
|
136
|
+
return { ...base, unlocked: false, allowed: false, overage_policy: row.overage_policy, reason: R.LOCKED };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Unlocked but not metered → nothing to count.
|
|
140
|
+
if (!feature.is_meterable || row.quota === null || row.quota === undefined) {
|
|
141
|
+
return { ...open(feature.is_meterable ? R.OK : R.NOT_METERED), overage_policy: row.overage_policy || "unlimited" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const granularity = row.period_granularity || "month";
|
|
145
|
+
const period = periodKey(granularity, { now, subscriptionId: subscription._id });
|
|
146
|
+
const subject_key = subjectKeyFor(row.quota_scope, identity);
|
|
147
|
+
const remaining = Math.max(0, Number(row.quota) - Number(used || 0));
|
|
148
|
+
const withinQuota = Number(used || 0) < Number(row.quota);
|
|
149
|
+
const policy = row.overage_policy || "block";
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
unlocked: true,
|
|
153
|
+
quota: Number(row.quota),
|
|
154
|
+
used: Number(used || 0),
|
|
155
|
+
remaining,
|
|
156
|
+
// Only `block` denies. single_usage/unlimited let the call through — billing catches up after.
|
|
157
|
+
allowed: withinQuota || policy !== "block",
|
|
158
|
+
overage_policy: policy,
|
|
159
|
+
reason: withinQuota ? R.OK : R.QUOTA_EXCEEDED,
|
|
160
|
+
period,
|
|
161
|
+
subject_key,
|
|
162
|
+
metered: true,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export default { resolveEntitlement, resolveEntitlementRow, periodKey, subjectKeyFor };
|