@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.
- package/README.md +128 -126
- package/package.json +57 -56
- 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/grid-schema.js +231 -231
- package/src/index.js +75 -74
- package/src/proration.js +155 -155
- package/src/reportee-tree.js +129 -129
- package/src/role-capabilities.js +93 -93
- package/src/route-features.js +292 -292
- package/src/route-screen.js +45 -0
- package/src/subscription-lifecycle.js +106 -106
- package/src/tenant-context.js +26 -26
- package/src/tenant-plugin.js +177 -177
- package/src/visible-when.js +108 -108
package/src/entitlement-store.js
CHANGED
|
@@ -1,610 +1,610 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The entitlement STORE — reading a subject's entitlement, and consuming a unit of it.
|
|
3
|
-
*
|
|
4
|
-
* `entitlement.js` next door holds the pure DECISION (given a subscription, rows and a usage
|
|
5
|
-
* count, what is allowed). This holds the part that talks to the database, because every backend
|
|
6
|
-
* that gates a metered feature needs exactly the same two operations and there is nothing
|
|
7
|
-
* app-specific about either.
|
|
8
|
-
*
|
|
9
|
-
* ── Why this exists ──────────────────────────────────────────────────────────────────────────
|
|
10
|
-
* v1 and v2 each grew their own copy of these two functions, and they had ALREADY diverged in
|
|
11
|
-
* shape before a third service needed them: v1's `entitlementFor` derives the subject from `req`
|
|
12
|
-
* and returns carried state for `consume` to reuse, while v2's takes explicit ids and splits
|
|
13
|
-
* `findSubscription` out. Two implementations of one contract is a bug waiting for the day they
|
|
14
|
-
* disagree about whether someone is over quota. A third would have made it certain.
|
|
15
|
-
*
|
|
16
|
-
* So the mechanism lives here and only the DATA differs — which database, which logger. Nothing
|
|
17
|
-
* Mongoose-shaped crosses this boundary: callers inject a `getDb()` returning a raw driver Db, so
|
|
18
|
-
* the package stays model-agnostic and services keep owning their own connections.
|
|
19
|
-
*
|
|
20
|
-
* ── Fail open, always ────────────────────────────────────────────────────────────────────────
|
|
21
|
-
* Entitlement never blocks on infrastructure error. RBAC is the layer that fails closed; this one
|
|
22
|
-
* is commercial, and refusing paid work because our own counter was unreachable turns a small
|
|
23
|
-
* outage into a customer-visible one. Absence is far more likely to be our gap than a customer
|
|
24
|
-
* genuinely owning nothing.
|
|
25
|
-
*/
|
|
26
|
-
import { resolveEntitlement, resolveEntitlementRow, periodKey, subjectKeyFor } from "./entitlement.js";
|
|
27
|
-
import { CREDITS_FEATURE_CODE } from "./entitlement-schema.js";
|
|
28
|
-
import { LIVE_STATUSES } from "./subscription-lifecycle.js";
|
|
29
|
-
|
|
30
|
-
/** Every failure path returns this, so callers never have to branch on "did the lookup work". */
|
|
31
|
-
const OPEN = (reason) => ({
|
|
32
|
-
allowed: true,
|
|
33
|
-
unlocked: true,
|
|
34
|
-
metered: false,
|
|
35
|
-
quota: null,
|
|
36
|
-
used: 0,
|
|
37
|
-
remaining: null,
|
|
38
|
-
overage_policy: "unlimited",
|
|
39
|
-
reason,
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
const noopLogger = { warn() {}, info() {} };
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Build a store bound to one service's database.
|
|
46
|
-
*
|
|
47
|
-
* @param {Object} opts
|
|
48
|
-
* @param {Function} opts.getDb returns a raw MongoDB `Db`, or null/undefined when unavailable
|
|
49
|
-
* @param {Function} [opts.toObjectId] cast a string id the way this service's driver expects.
|
|
50
|
-
* Defaults to passing the value through — services using
|
|
51
|
-
* Mongoose should pass `mongoose.Types.ObjectId`, because a
|
|
52
|
-
* string will not match an ObjectId field and the customer
|
|
53
|
-
* would silently look unsubscribed.
|
|
54
|
-
* @param {Object} [opts.logger] anything with `warn`
|
|
55
|
-
*/
|
|
56
|
-
export function createEntitlementStore({
|
|
57
|
-
getDb,
|
|
58
|
-
toObjectId = (v) => v,
|
|
59
|
-
logger = noopLogger,
|
|
60
|
-
/**
|
|
61
|
-
* THE subscription master switch.
|
|
62
|
-
*
|
|
63
|
-
* One place that makes the whole domain inert: no entitlement decides anything, no counter
|
|
64
|
-
* moves, no subject is auto-provisioned. Per-feature `rollout_mode` still governs which gates
|
|
65
|
-
* are live once this is on — this is the switch above all of them, for shipping the code long
|
|
66
|
-
* before the product.
|
|
67
|
-
*
|
|
68
|
-
* OFF by default, deliberately. A release must be able to carry every line of this and change
|
|
69
|
-
* nothing for a single user; a default of "on" would make that depend on remembering to set a
|
|
70
|
-
* flag, and the failure mode is customers being charged and refused.
|
|
71
|
-
*
|
|
72
|
-
* Resolved by the CALLER — env, a config document, whatever each service already uses — so this
|
|
73
|
-
* package keeps no opinion about where configuration lives.
|
|
74
|
-
*/
|
|
75
|
-
isEnabled = () => String(process.env.SUBSCRIPTION_ENABLED || "").toLowerCase() === "true",
|
|
76
|
-
} = {}) {
|
|
77
|
-
if (typeof getDb !== "function") throw new Error("createEntitlementStore requires getDb()");
|
|
78
|
-
|
|
79
|
-
/** Resolved per call, never cached, so flipping the switch takes effect without a restart. */
|
|
80
|
-
const enabled = async () => {
|
|
81
|
-
try { return (await isEnabled()) === true; } catch { return false; }
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const oid = (v) => {
|
|
85
|
-
try { return toObjectId(String(v)); } catch { return v; }
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
/** The live subscription governing this subject, or null. */
|
|
89
|
-
async function findSubscription({ subjectType, subjectId } = {}) {
|
|
90
|
-
const db = getDb();
|
|
91
|
-
if (!db || !subjectType || !subjectId) return null;
|
|
92
|
-
return db.collection("account_subscriptions").findOne({
|
|
93
|
-
subject_type: subjectType,
|
|
94
|
-
subject_id: oid(subjectId),
|
|
95
|
-
status: { $in: LIVE_STATUSES },
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Resolve entitlement for one feature.
|
|
101
|
-
*
|
|
102
|
-
* Returns an OPEN result whenever anything is missing — no subscription, no plan row, no
|
|
103
|
-
* database — for the reason in the header.
|
|
104
|
-
*/
|
|
105
|
-
async function entitlementFor({
|
|
106
|
-
subjectType, subjectId, featureCode, feature = {}, identity = {},
|
|
107
|
-
bypass = false, now = new Date(),
|
|
108
|
-
} = {}) {
|
|
109
|
-
try {
|
|
110
|
-
// Master switch off ⇒ the same OPEN answer as "no subscription": allowed, unshaped,
|
|
111
|
-
// unmetered. Every caller already handles this, because it is the fail-open path.
|
|
112
|
-
if (!(await enabled())) return OPEN("subscription_disabled");
|
|
113
|
-
const db = getDb();
|
|
114
|
-
if (!db || !subjectType || !subjectId) return OPEN("no_subject");
|
|
115
|
-
|
|
116
|
-
const subscription = await findSubscription({ subjectType, subjectId });
|
|
117
|
-
if (!subscription) return resolveEntitlement({ subscription: null, featureCode, feature, bypass, now });
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Trial terms: while `trialing`, rows with cycle "trial" take precedence over the purchased
|
|
121
|
-
* cycle's — that is how a trial offers LESS than the paid plan without being a separate
|
|
122
|
-
* plan. No trial rows ⇒ the paid terms apply during trial, which is the
|
|
123
|
-
* time-limited-full-plan case.
|
|
124
|
-
*/
|
|
125
|
-
const cycles = subscription.status === "trialing"
|
|
126
|
-
? ["trial", subscription.cycle]
|
|
127
|
-
: [subscription.cycle];
|
|
128
|
-
/**
|
|
129
|
-
* Fetched together: the feature's own rows AND the plan's credit-allowance rows. A plan IS
|
|
130
|
-
* credits-mode when a SUBSCRIPTION.CREDITS row resolves — the mode is the data, so there is
|
|
131
|
-
* no flag to drift out of step with it.
|
|
132
|
-
*/
|
|
133
|
-
const fetched = await db.collection("plan_entitlements").find({
|
|
134
|
-
plan_code: subscription.plan_code,
|
|
135
|
-
cycle: { $in: cycles },
|
|
136
|
-
feature_code: featureCode === CREDITS_FEATURE_CODE
|
|
137
|
-
? featureCode
|
|
138
|
-
: { $in: [featureCode, CREDITS_FEATURE_CODE] },
|
|
139
|
-
}).toArray();
|
|
140
|
-
const pick = (code) => {
|
|
141
|
-
const mine = fetched.filter((r) => r.feature_code === code);
|
|
142
|
-
const trial = mine.filter((r) => r.cycle === "trial");
|
|
143
|
-
return subscription.status === "trialing" && trial.length
|
|
144
|
-
? trial
|
|
145
|
-
: mine.filter((r) => r.cycle === subscription.cycle);
|
|
146
|
-
};
|
|
147
|
-
const rows = pick(featureCode);
|
|
148
|
-
const creditRows = featureCode === CREDITS_FEATURE_CODE ? [] : pick(CREDITS_FEATURE_CODE);
|
|
149
|
-
const creditsMode = creditRows.length > 0 && feature.is_meterable;
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* CREDITS MODE — the feature's own row says whether it is INCLUDED (and may override the
|
|
153
|
-
* cost); the guard runs against the shared credit allowance, priced per use.
|
|
154
|
-
*
|
|
155
|
-
* cost = plan row's credit_cost > service's registry credit_cost > 1
|
|
156
|
-
*
|
|
157
|
-
* The per-plan override is your "premium plans give more usage" lever: eCAS can cost 2 on
|
|
158
|
-
* Discover and 1 on Grow without any per-feature matrix returning through the back door.
|
|
159
|
-
*/
|
|
160
|
-
/**
|
|
161
|
-
* A per-feature BUDGET opts out of credits mode.
|
|
162
|
-
*
|
|
163
|
-
* `quota` is overloaded, and the two meanings need opposite handling: on VPD it is "12
|
|
164
|
-
* sessions a year" — a budget that must decrement and refuse at zero — while on stock tips it
|
|
165
|
-
* is "5 ideas per answer", a ceiling read on every request and never spent. Credits mode
|
|
166
|
-
* counts only the wallet, so a budget expressed that way silently never moved: Grow showed
|
|
167
|
-
* 12 VPD sessions that could be used forever.
|
|
168
|
-
*
|
|
169
|
-
* So a row with a real number, on a feature that does NOT shape a response, falls through to
|
|
170
|
-
* count mode and is governed by its own counter. Credits stay for what is priced in credits.
|
|
171
|
-
* `shapes_response` is the discriminator because it is a property of the FEATURE — whether it
|
|
172
|
-
* trims an answer or counts a use — not of the plan that sells it.
|
|
173
|
-
*/
|
|
174
|
-
const budgetRow = creditsMode ? resolveEntitlementRow(rows, featureCode, now) : null;
|
|
175
|
-
const isOwnBudget = !!budgetRow
|
|
176
|
-
&& budgetRow.unlocked !== false
|
|
177
|
-
&& feature.shapes_response !== true
|
|
178
|
-
&& budgetRow.quota !== null && budgetRow.quota !== undefined && budgetRow.quota !== ""
|
|
179
|
-
&& Number.isFinite(Number(budgetRow.quota));
|
|
180
|
-
|
|
181
|
-
if (creditsMode && !isOwnBudget) {
|
|
182
|
-
const svcRow = resolveEntitlementRow(rows, featureCode, now);
|
|
183
|
-
// Not named in a credits plan → the same fail-open NOT_IN_PLAN as count mode. A plan
|
|
184
|
-
// that forgot to include a feature is a config gap, not a paywall.
|
|
185
|
-
if (svcRow && svcRow.unlocked !== false) {
|
|
186
|
-
// UNSET falls through to the next layer; 0 does not — it is the plan saying "free".
|
|
187
|
-
const priced = (v) => (v === null || v === undefined || v === "" || !Number.isFinite(Number(v)) || Number(v) < 0
|
|
188
|
-
? null : Number(v));
|
|
189
|
-
const cost = priced(svcRow.credit_cost) ?? priced(feature.credit_cost) ?? 1;
|
|
190
|
-
|
|
191
|
-
let packBalance = 0;
|
|
192
|
-
try {
|
|
193
|
-
const bal = await db.collection("feature_credits").aggregate([
|
|
194
|
-
{ $match: {
|
|
195
|
-
subject_type: subjectType,
|
|
196
|
-
subject_id: oid(subjectId),
|
|
197
|
-
feature_code: CREDITS_FEATURE_CODE,
|
|
198
|
-
$expr: { $lt: ["$consumed", "$purchased"] },
|
|
199
|
-
$or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
|
|
200
|
-
} },
|
|
201
|
-
{ $group: { _id: null, n: { $sum: { $subtract: ["$purchased", "$consumed"] } } } },
|
|
202
|
-
]).toArray();
|
|
203
|
-
packBalance = bal[0]?.n || 0;
|
|
204
|
-
} catch (e) {
|
|
205
|
-
logger.warn(`[entitlement] credit-pack balance unreadable: ${e.message}`);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const creditRow = creditRows[0];
|
|
209
|
-
const period = periodKey(creditRow.period_granularity || "month", {
|
|
210
|
-
now, subscriptionId: subscription._id, periodStart: subscription.period_start,
|
|
211
|
-
});
|
|
212
|
-
const subjectKey = subjectKeyFor(creditRow.quota_scope, identity) || "";
|
|
213
|
-
const usage = await db.collection("subscription_usage").findOne({
|
|
214
|
-
subscription_id: subscription._id,
|
|
215
|
-
feature_code: CREDITS_FEATURE_CODE,
|
|
216
|
-
period,
|
|
217
|
-
subject_key: subjectKey,
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
const result = await resolveEntitlement({
|
|
221
|
-
subscription,
|
|
222
|
-
entitlementRows: creditRows,
|
|
223
|
-
featureCode: CREDITS_FEATURE_CODE,
|
|
224
|
-
feature: { is_meterable: true },
|
|
225
|
-
used: usage?.used || 0,
|
|
226
|
-
credits: packBalance,
|
|
227
|
-
identity, bypass, now, cost,
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
const effectiveCreditRow = creditRows[0]
|
|
231
|
-
? { ...creditRows[0], quota: result.quota, overage_policy: result.overage_policy || creditRows[0].overage_policy }
|
|
232
|
-
: null;
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* A CEILING survives credits mode.
|
|
236
|
-
*
|
|
237
|
-
* `limit` shapes a response — "your list shows 5" — and is read on every request without
|
|
238
|
-
* ever being spent. A PRICE is what a use costs. They are orthogonal, and the two live on
|
|
239
|
-
* different fields for exactly that reason.
|
|
240
|
-
*
|
|
241
|
-
* Without this line the credits branch returned the wallet's numbers and no `limit` at
|
|
242
|
-
* all, so a consumer reading `ent.limit` (NFD AI's stock-tips shaper does) saw nothing
|
|
243
|
-
* and applied no ceiling. A free plan promising five stock ideas silently served every
|
|
244
|
-
* one of them — the paywall looked configured and was not.
|
|
245
|
-
*
|
|
246
|
-
* Same UNSET-vs-0 care as count mode: null means "no ceiling", 0 means "show nothing".
|
|
247
|
-
*/
|
|
248
|
-
const rawCeiling = svcRow.quota;
|
|
249
|
-
const ceiling = rawCeiling === null || rawCeiling === undefined || rawCeiling === ""
|
|
250
|
-
? null
|
|
251
|
-
: Number(rawCeiling);
|
|
252
|
-
|
|
253
|
-
return {
|
|
254
|
-
...result,
|
|
255
|
-
limit: ceiling !== null && Number.isFinite(ceiling) && ceiling >= 0 ? ceiling : null,
|
|
256
|
-
mode: "credits",
|
|
257
|
-
carriedSubjectType: subjectType,
|
|
258
|
-
carriedSubjectId: subjectId,
|
|
259
|
-
carriedSubscriptionId: subscription._id,
|
|
260
|
-
carriedPeriodStart: subscription.period_start || null,
|
|
261
|
-
carriedIdentity: identity,
|
|
262
|
-
carriedRow: effectiveCreditRow,
|
|
263
|
-
// consume() counts against THIS bucket, at THIS price — never recomputed.
|
|
264
|
-
carriedFeatureCode: CREDITS_FEATURE_CODE,
|
|
265
|
-
carriedCost: cost,
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* À-la-carte credits — units bought singly. Summed across purchases that still have units
|
|
272
|
-
* left and have not expired. Read alongside usage because the resolver needs both to say
|
|
273
|
-
* whether the customer may proceed.
|
|
274
|
-
*/
|
|
275
|
-
let credits = 0;
|
|
276
|
-
if (feature.is_meterable || feature.single_usage_purchasable) {
|
|
277
|
-
// Guarded separately: credits are a top-up, and failing to read them must not take the
|
|
278
|
-
// quota check down with it. Losing a purchased unit is bad; silently ceasing to meter
|
|
279
|
-
// everyone because one collection is unreachable is worse.
|
|
280
|
-
try {
|
|
281
|
-
const bal = await db.collection("feature_credits").aggregate([
|
|
282
|
-
{
|
|
283
|
-
$match: {
|
|
284
|
-
subject_type: subjectType,
|
|
285
|
-
subject_id: oid(subjectId),
|
|
286
|
-
feature_code: featureCode,
|
|
287
|
-
$expr: { $lt: ["$consumed", "$purchased"] },
|
|
288
|
-
$or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
|
|
289
|
-
},
|
|
290
|
-
},
|
|
291
|
-
{ $group: { _id: null, n: { $sum: { $subtract: ["$purchased", "$consumed"] } } } },
|
|
292
|
-
]).toArray();
|
|
293
|
-
credits = bal[0]?.n || 0;
|
|
294
|
-
} catch (e) {
|
|
295
|
-
logger.warn(`[entitlement] credit balance unreadable for ${featureCode}: ${e.message}`);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Only read the counter when something could actually be metered — an unmetered feature
|
|
300
|
-
// should not cost a query on every request.
|
|
301
|
-
let used = 0;
|
|
302
|
-
const row = rows[0] || null;
|
|
303
|
-
if (feature.is_meterable && row) {
|
|
304
|
-
const period = periodKey(row.period_granularity || "month", { now, subscriptionId: subscription._id, periodStart: subscription.period_start });
|
|
305
|
-
const subjectKey = subjectKeyFor(row.quota_scope, identity) || "";
|
|
306
|
-
const usage = await db.collection("subscription_usage").findOne({
|
|
307
|
-
subscription_id: subscription._id,
|
|
308
|
-
feature_code: featureCode,
|
|
309
|
-
period,
|
|
310
|
-
subject_key: subjectKey,
|
|
311
|
-
});
|
|
312
|
-
used = usage?.used || 0;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const result = await resolveEntitlement({
|
|
316
|
-
subscription, entitlementRows: rows, featureCode, feature, used, credits, identity, bypass, now,
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Carry what consume() needs, so it cannot recompute the period key and count against a
|
|
321
|
-
* different bucket than the one that was just checked.
|
|
322
|
-
*
|
|
323
|
-
* The carried row takes its ceiling from the RESOLVED result, not from the raw plan row —
|
|
324
|
-
* because a per-subject override changes the decision without changing the row it came
|
|
325
|
-
* from. Carrying the raw row meant consume guarded on the plan's quota while the check had
|
|
326
|
-
* already passed on the overridden one: a subject granted 5 was refused at 2, and it
|
|
327
|
-
* surfaced as a counter that had simply stopped moving.
|
|
328
|
-
*/
|
|
329
|
-
const effectiveRow = row
|
|
330
|
-
? { ...row, quota: result.quota, overage_policy: result.overage_policy || row.overage_policy }
|
|
331
|
-
: null;
|
|
332
|
-
|
|
333
|
-
return {
|
|
334
|
-
...result,
|
|
335
|
-
carriedSubjectType: subjectType,
|
|
336
|
-
carriedSubjectId: subjectId,
|
|
337
|
-
carriedSubscriptionId: subscription._id,
|
|
338
|
-
carriedPeriodStart: subscription.period_start || null,
|
|
339
|
-
carriedIdentity: identity,
|
|
340
|
-
carriedRow: effectiveRow,
|
|
341
|
-
};
|
|
342
|
-
} catch (e) {
|
|
343
|
-
logger.warn(`[entitlement] resolve failed for ${featureCode}, allowing through: ${e.message}`);
|
|
344
|
-
return OPEN("resolve_error");
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Per-service breakdown when a shared bucket pays — "where did my 500 credits go".
|
|
350
|
-
*
|
|
351
|
-
* Fire-and-forget and unguarded: it is reporting, not enforcement, and a failed attribution
|
|
352
|
-
* row must never undo a consumption the guard already allowed. Only written when the bucket
|
|
353
|
-
* differs from the feature (credits mode) — in count mode the guarded row IS the attribution.
|
|
354
|
-
*/
|
|
355
|
-
function recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now }) {
|
|
356
|
-
if (!db || bucketCode === featureCode) return;
|
|
357
|
-
try {
|
|
358
|
-
const row = entitlement?.carriedRow || {};
|
|
359
|
-
const period = periodKey(row.period_granularity || "month", {
|
|
360
|
-
now, subscriptionId: subId, periodStart: entitlement?.carriedPeriodStart,
|
|
361
|
-
});
|
|
362
|
-
const subjectKey = subjectKeyFor(row.quota_scope, ident) || "";
|
|
363
|
-
db.collection("subscription_usage").updateOne(
|
|
364
|
-
{ subscription_id: subId, feature_code: featureCode, period, subject_key: subjectKey },
|
|
365
|
-
{ $inc: { used: n, credits_spent: units }, $setOnInsert: { attribution: true } },
|
|
366
|
-
{ upsert: true },
|
|
367
|
-
).catch((e) => logger.warn(`[entitlement] attribution write failed: ${e.message}`));
|
|
368
|
-
} catch (e) {
|
|
369
|
-
logger.warn(`[entitlement] attribution skipped: ${e.message}`);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* Consume one unit — race-free by construction.
|
|
375
|
-
*
|
|
376
|
-
* The `used < quota` test lives inside the FILTER, so Mongo decides atomically whether the
|
|
377
|
-
* increment may happen; matchedCount 0 means the ceiling was already reached. A read-then-write
|
|
378
|
-
* would let two concurrent requests both pass a 5-of-5 check and land a 6th.
|
|
379
|
-
*
|
|
380
|
-
* Only `block` enforces the ceiling — under `warn` the count still rises, and that record is the
|
|
381
|
-
* evidence for whether a quota is right before anyone is ever refused.
|
|
382
|
-
*
|
|
383
|
-
* Accepts either the result of `entitlementFor` (preferred — it carries the exact bucket that
|
|
384
|
-
* was checked) or the explicit pieces, so both existing call shapes keep working.
|
|
385
|
-
*/
|
|
386
|
-
async function consume({
|
|
387
|
-
entitlement, featureCode, n = 1, now = new Date(),
|
|
388
|
-
subscriptionId, row, identity, periodStart,
|
|
389
|
-
} = {}) {
|
|
390
|
-
try {
|
|
391
|
-
const db = getDb();
|
|
392
|
-
const subId = subscriptionId || entitlement?.carriedSubscriptionId;
|
|
393
|
-
const useRow = row || entitlement?.carriedRow;
|
|
394
|
-
const ident = identity || entitlement?.carriedIdentity || {};
|
|
395
|
-
// An unmetered feature has nothing to count; saying so is not a failure.
|
|
396
|
-
/**
|
|
397
|
-
* Checked before anything else, including the not-metered short-circuits: "the domain is
|
|
398
|
-
* off" is a truer answer than "this feature is not metered", and it has to hold even when
|
|
399
|
-
* the caller passed nothing useful. Returning `consumed: false` rather than throwing keeps
|
|
400
|
-
* callers on the path they already have for "the counter did not move".
|
|
401
|
-
*/
|
|
402
|
-
if (!(await enabled())) return { consumed: false, reason: "subscription_disabled" };
|
|
403
|
-
|
|
404
|
-
if (entitlement && !entitlement.metered) return { consumed: false, reason: "not_metered" };
|
|
405
|
-
if (!db || !subId || !useRow) return { consumed: false, reason: "not_metered" };
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Credits mode: the BUCKET is the plan's credit allowance, and one action costs `cost`
|
|
409
|
-
* units. Both were decided by the resolve that authorised this call — recomputing either
|
|
410
|
-
* here could draw from a bucket nobody checked, at a price nobody quoted.
|
|
411
|
-
*/
|
|
412
|
-
const bucketCode = entitlement?.carriedFeatureCode || featureCode;
|
|
413
|
-
const carried = Number(entitlement?.carriedCost);
|
|
414
|
-
const units = n * (Number.isFinite(carried) && carried >= 0 ? carried : 1);
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* A free action inside a credits plan: authorised, but there is no balance to move. Writing
|
|
418
|
-
* a zero increment would still create the row and make it look like something was spent.
|
|
419
|
-
*/
|
|
420
|
-
if (units === 0) return { consumed: true, free: true };
|
|
421
|
-
|
|
422
|
-
// The SAME bucket that entitlementFor just checked — recomputing it from different inputs
|
|
423
|
-
// would count against a period nobody validated.
|
|
424
|
-
const start = periodStart !== undefined ? periodStart : entitlement?.carriedPeriodStart;
|
|
425
|
-
/**
|
|
426
|
-
* Spending a purchased credit instead of the plan's allowance.
|
|
427
|
-
*
|
|
428
|
-
* `usingCredit` was decided by the same resolve that authorised this call, so the two cannot
|
|
429
|
-
* disagree about which bucket was checked and which is being drawn down. The guard
|
|
430
|
-
* `consumed < purchased` sits INSIDE the filter for the same reason the quota test does:
|
|
431
|
-
* Mongo decides atomically, so two concurrent calls cannot both spend the last unit.
|
|
432
|
-
*
|
|
433
|
-
* Oldest purchase first — a credit with an expiry should be used before one without.
|
|
434
|
-
*/
|
|
435
|
-
if (entitlement?.usingCredit) {
|
|
436
|
-
const hit = await db.collection("feature_credits").findOneAndUpdate(
|
|
437
|
-
{
|
|
438
|
-
subject_type: entitlement.carriedSubjectType,
|
|
439
|
-
subject_id: oid(entitlement.carriedSubjectId),
|
|
440
|
-
feature_code: bucketCode,
|
|
441
|
-
/**
|
|
442
|
-
* The WHOLE cost must fit in one pack row, decided atomically. A 2-credit action
|
|
443
|
-
* against a pack with 1 left is refused rather than split across purchases —
|
|
444
|
-
* splitting would need a transaction across rows, and the boundary case (the last
|
|
445
|
-
* unit of one pack) is not worth that machinery. The refusal falls through to the
|
|
446
|
-
* plan counter, which answers honestly.
|
|
447
|
-
*/
|
|
448
|
-
$expr: { $lte: [{ $add: ["$consumed", units] }, "$purchased"] },
|
|
449
|
-
$or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
|
|
450
|
-
},
|
|
451
|
-
{ $inc: { consumed: units } },
|
|
452
|
-
{ sort: { expires_at: 1, createdAt: 1 } },
|
|
453
|
-
);
|
|
454
|
-
if (hit) {
|
|
455
|
-
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
456
|
-
return { consumed: true, fromCredit: true };
|
|
457
|
-
}
|
|
458
|
-
// No credit left after all — fall through and let the plan counter answer, which may well
|
|
459
|
-
// refuse. Better than silently succeeding on a balance that just went to zero.
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
const period = periodKey(useRow.period_granularity || "month", { now, subscriptionId: subId, periodStart: start });
|
|
463
|
-
const subjectKey = subjectKeyFor(useRow.quota_scope, ident) || "";
|
|
464
|
-
const filter = {
|
|
465
|
-
subscription_id: subId,
|
|
466
|
-
feature_code: bucketCode,
|
|
467
|
-
period,
|
|
468
|
-
subject_key: subjectKey,
|
|
469
|
-
};
|
|
470
|
-
const policy = useRow.overage_policy || "block";
|
|
471
|
-
const capped = policy === "block" && useRow.quota != null;
|
|
472
|
-
|
|
473
|
-
// Unguarded (warn / unlimited): a plain upsert is right — the count rises either way, and
|
|
474
|
-
// that record is the evidence for whether a quota is set correctly before anyone is refused.
|
|
475
|
-
if (!capped) {
|
|
476
|
-
await db.collection("subscription_usage").updateOne(
|
|
477
|
-
filter, { $inc: { used: units }, $setOnInsert: { ...filter } }, { upsert: true },
|
|
478
|
-
);
|
|
479
|
-
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
480
|
-
return { consumed: true };
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// Capped: try the GUARDED update first, without upsert. Cost-aware — the whole cost must
|
|
484
|
-
// fit ($lte quota - units is exactly $lt quota when units is 1), and a balance never goes
|
|
485
|
-
// negative on an in-flight action.
|
|
486
|
-
const guarded = { ...filter, used: { $lte: Number(useRow.quota) - units } };
|
|
487
|
-
const hit = await db.collection("subscription_usage").updateOne(guarded, { $inc: { used: units } });
|
|
488
|
-
if (hit.matchedCount > 0) {
|
|
489
|
-
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
490
|
-
return { consumed: true };
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// No match means either "no row yet" (first use this period) or "quota reached". Distinguish
|
|
494
|
-
// by INSERTING — the unique index makes that safe under concurrency: exactly one racer wins
|
|
495
|
-
// and the loser's duplicate-key means the row now exists, i.e. the quota case.
|
|
496
|
-
//
|
|
497
|
-
// Doing this as an upsert on the guarded filter instead would rely on a duplicate-key
|
|
498
|
-
// EXCEPTION to mean "quota exceeded" — correct by accident, and unreadable.
|
|
499
|
-
try {
|
|
500
|
-
// First use this period must ALSO fit — an allowance smaller than one action's cost is
|
|
501
|
-
// refused on action one, not discovered at minus-something.
|
|
502
|
-
if (Number(useRow.quota) - units < 0) return { consumed: false, reason: "quota_exceeded" };
|
|
503
|
-
await db.collection("subscription_usage").insertOne({ ...filter, used: units, createdAt: new Date() });
|
|
504
|
-
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
505
|
-
return { consumed: true };
|
|
506
|
-
} catch (e) {
|
|
507
|
-
if (e?.code === 11000) return { consumed: false, reason: "quota_exceeded" };
|
|
508
|
-
throw e;
|
|
509
|
-
}
|
|
510
|
-
} catch (e) {
|
|
511
|
-
logger.warn(`[entitlement] consume failed for ${featureCode}: ${e.message}`);
|
|
512
|
-
// Fail open on infrastructure error: never refuse a request because our counter misbehaved.
|
|
513
|
-
return { consumed: false, reason: "error" };
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* Give a subject the default plan, if there is one and they have none.
|
|
519
|
-
*
|
|
520
|
-
* A distributor who has never bought anything currently resolves to "no subscription", which
|
|
521
|
-
* fails OPEN — so today every distributor silently has unlimited everything. A default plan is
|
|
522
|
-
* how that becomes deliberate: a real package with real limits, assigned automatically, so the
|
|
523
|
-
* system's answer to "what may this person do" stops being "we never decided".
|
|
524
|
-
*
|
|
525
|
-
* ── Idempotent, and safe to call from anywhere ───────────────────────────────────────────────
|
|
526
|
-
* Returns the existing subscription untouched if one is live. That matters because the natural
|
|
527
|
-
* call site is distributor creation, and distributors get created down several paths (self
|
|
528
|
-
* onboarding, admin, draft approval, bulk import) — a provisioner that could double-assign would
|
|
529
|
-
* have to be wired carefully into exactly one of them, which is how coverage gaps happen. This
|
|
530
|
-
* one can be called from all of them, or twice, or after the fact.
|
|
531
|
-
*
|
|
532
|
-
* ── Never fatal ──────────────────────────────────────────────────────────────────────────────
|
|
533
|
-
* Returns null on any failure rather than throwing. Callers create distributors; a subscription
|
|
534
|
-
* that could not be provisioned must never be the reason a distributor does not exist. The gap
|
|
535
|
-
* is recoverable by calling this again — a failed creation is not.
|
|
536
|
-
*
|
|
537
|
-
* @param {Object} p
|
|
538
|
-
* @param {string} p.subjectType "distributor" | "client"
|
|
539
|
-
* @param {string} p.subjectId
|
|
540
|
-
* @param {string} [p.accountId]
|
|
541
|
-
* @param {Date} [p.now]
|
|
542
|
-
* @returns {Promise<Object|null>} the subscription (existing or new), or null
|
|
543
|
-
*/
|
|
544
|
-
async function provisionDefault({ subjectType, subjectId, accountId = null, now = new Date() } = {}) {
|
|
545
|
-
try {
|
|
546
|
-
// No auto-provisioning while the domain is off, whatever the catalogue says. This is the
|
|
547
|
-
// one that would otherwise write rows into production on the first distributor created.
|
|
548
|
-
if (!(await enabled())) return null;
|
|
549
|
-
|
|
550
|
-
const db = getDb();
|
|
551
|
-
if (!db || !subjectType || !subjectId) return null;
|
|
552
|
-
|
|
553
|
-
const existing = await findSubscription({ subjectType, subjectId });
|
|
554
|
-
if (existing) return existing;
|
|
555
|
-
|
|
556
|
-
// The default plan is a property of the CATALOGUE, not of this code — so which package new
|
|
557
|
-
// distributors get, and for how long, is changed by editing a plan rather than deploying.
|
|
558
|
-
const plan = await db.collection("plan_catalog").findOne({
|
|
559
|
-
default_for: subjectType,
|
|
560
|
-
status: "active",
|
|
561
|
-
});
|
|
562
|
-
if (!plan) return null;
|
|
563
|
-
|
|
564
|
-
const days = Number(plan.default_duration_days) || 0;
|
|
565
|
-
const start = new Date(now);
|
|
566
|
-
const end = new Date(now);
|
|
567
|
-
if (days > 0) end.setDate(end.getDate() + days);
|
|
568
|
-
// No duration means an open-ended default tier; period_end null reads as "does not lapse".
|
|
569
|
-
const periodEnd = days > 0 ? end : null;
|
|
570
|
-
|
|
571
|
-
/**
|
|
572
|
-
* Status is `active`, not `trialing`.
|
|
573
|
-
*
|
|
574
|
-
* This is a real package on real terms, not a taste of a bigger one — and `trialing` would
|
|
575
|
-
* make trial-cycle entitlement rows take precedence, quietly applying terms nobody wrote for
|
|
576
|
-
* this plan. The trial mechanism stays available for plans that actually want it.
|
|
577
|
-
*/
|
|
578
|
-
const doc = {
|
|
579
|
-
account_id: accountId ? oid(accountId) : null,
|
|
580
|
-
subject_type: subjectType,
|
|
581
|
-
subject_id: oid(subjectId),
|
|
582
|
-
plan_code: plan.plan_code,
|
|
583
|
-
cycle: plan.default_cycle || "monthly",
|
|
584
|
-
status: "active",
|
|
585
|
-
period_start: start,
|
|
586
|
-
period_end: periodEnd,
|
|
587
|
-
provisioned_default: true, // so a report can tell "given" from "bought"
|
|
588
|
-
createdAt: new Date(),
|
|
589
|
-
};
|
|
590
|
-
|
|
591
|
-
try {
|
|
592
|
-
await db.collection("account_subscriptions").insertOne(doc);
|
|
593
|
-
} catch (e) {
|
|
594
|
-
// Two creation paths racing on the same subject. The partial unique index on a live
|
|
595
|
-
// subscription is what makes this safe: one wins, and the loser reads the winner's row
|
|
596
|
-
// rather than creating a duplicate.
|
|
597
|
-
if (e?.code === 11000) return findSubscription({ subjectType, subjectId });
|
|
598
|
-
throw e;
|
|
599
|
-
}
|
|
600
|
-
return doc;
|
|
601
|
-
} catch (e) {
|
|
602
|
-
logger.warn(`[entitlement] default provisioning failed for ${subjectType} ${subjectId}: ${e.message}`);
|
|
603
|
-
return null;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
return { findSubscription, entitlementFor, consume, provisionDefault };
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
export default { createEntitlementStore };
|
|
1
|
+
/**
|
|
2
|
+
* The entitlement STORE — reading a subject's entitlement, and consuming a unit of it.
|
|
3
|
+
*
|
|
4
|
+
* `entitlement.js` next door holds the pure DECISION (given a subscription, rows and a usage
|
|
5
|
+
* count, what is allowed). This holds the part that talks to the database, because every backend
|
|
6
|
+
* that gates a metered feature needs exactly the same two operations and there is nothing
|
|
7
|
+
* app-specific about either.
|
|
8
|
+
*
|
|
9
|
+
* ── Why this exists ──────────────────────────────────────────────────────────────────────────
|
|
10
|
+
* v1 and v2 each grew their own copy of these two functions, and they had ALREADY diverged in
|
|
11
|
+
* shape before a third service needed them: v1's `entitlementFor` derives the subject from `req`
|
|
12
|
+
* and returns carried state for `consume` to reuse, while v2's takes explicit ids and splits
|
|
13
|
+
* `findSubscription` out. Two implementations of one contract is a bug waiting for the day they
|
|
14
|
+
* disagree about whether someone is over quota. A third would have made it certain.
|
|
15
|
+
*
|
|
16
|
+
* So the mechanism lives here and only the DATA differs — which database, which logger. Nothing
|
|
17
|
+
* Mongoose-shaped crosses this boundary: callers inject a `getDb()` returning a raw driver Db, so
|
|
18
|
+
* the package stays model-agnostic and services keep owning their own connections.
|
|
19
|
+
*
|
|
20
|
+
* ── Fail open, always ────────────────────────────────────────────────────────────────────────
|
|
21
|
+
* Entitlement never blocks on infrastructure error. RBAC is the layer that fails closed; this one
|
|
22
|
+
* is commercial, and refusing paid work because our own counter was unreachable turns a small
|
|
23
|
+
* outage into a customer-visible one. Absence is far more likely to be our gap than a customer
|
|
24
|
+
* genuinely owning nothing.
|
|
25
|
+
*/
|
|
26
|
+
import { resolveEntitlement, resolveEntitlementRow, periodKey, subjectKeyFor } from "./entitlement.js";
|
|
27
|
+
import { CREDITS_FEATURE_CODE } from "./entitlement-schema.js";
|
|
28
|
+
import { LIVE_STATUSES } from "./subscription-lifecycle.js";
|
|
29
|
+
|
|
30
|
+
/** Every failure path returns this, so callers never have to branch on "did the lookup work". */
|
|
31
|
+
const OPEN = (reason) => ({
|
|
32
|
+
allowed: true,
|
|
33
|
+
unlocked: true,
|
|
34
|
+
metered: false,
|
|
35
|
+
quota: null,
|
|
36
|
+
used: 0,
|
|
37
|
+
remaining: null,
|
|
38
|
+
overage_policy: "unlimited",
|
|
39
|
+
reason,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const noopLogger = { warn() {}, info() {} };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a store bound to one service's database.
|
|
46
|
+
*
|
|
47
|
+
* @param {Object} opts
|
|
48
|
+
* @param {Function} opts.getDb returns a raw MongoDB `Db`, or null/undefined when unavailable
|
|
49
|
+
* @param {Function} [opts.toObjectId] cast a string id the way this service's driver expects.
|
|
50
|
+
* Defaults to passing the value through — services using
|
|
51
|
+
* Mongoose should pass `mongoose.Types.ObjectId`, because a
|
|
52
|
+
* string will not match an ObjectId field and the customer
|
|
53
|
+
* would silently look unsubscribed.
|
|
54
|
+
* @param {Object} [opts.logger] anything with `warn`
|
|
55
|
+
*/
|
|
56
|
+
export function createEntitlementStore({
|
|
57
|
+
getDb,
|
|
58
|
+
toObjectId = (v) => v,
|
|
59
|
+
logger = noopLogger,
|
|
60
|
+
/**
|
|
61
|
+
* THE subscription master switch.
|
|
62
|
+
*
|
|
63
|
+
* One place that makes the whole domain inert: no entitlement decides anything, no counter
|
|
64
|
+
* moves, no subject is auto-provisioned. Per-feature `rollout_mode` still governs which gates
|
|
65
|
+
* are live once this is on — this is the switch above all of them, for shipping the code long
|
|
66
|
+
* before the product.
|
|
67
|
+
*
|
|
68
|
+
* OFF by default, deliberately. A release must be able to carry every line of this and change
|
|
69
|
+
* nothing for a single user; a default of "on" would make that depend on remembering to set a
|
|
70
|
+
* flag, and the failure mode is customers being charged and refused.
|
|
71
|
+
*
|
|
72
|
+
* Resolved by the CALLER — env, a config document, whatever each service already uses — so this
|
|
73
|
+
* package keeps no opinion about where configuration lives.
|
|
74
|
+
*/
|
|
75
|
+
isEnabled = () => String(process.env.SUBSCRIPTION_ENABLED || "").toLowerCase() === "true",
|
|
76
|
+
} = {}) {
|
|
77
|
+
if (typeof getDb !== "function") throw new Error("createEntitlementStore requires getDb()");
|
|
78
|
+
|
|
79
|
+
/** Resolved per call, never cached, so flipping the switch takes effect without a restart. */
|
|
80
|
+
const enabled = async () => {
|
|
81
|
+
try { return (await isEnabled()) === true; } catch { return false; }
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const oid = (v) => {
|
|
85
|
+
try { return toObjectId(String(v)); } catch { return v; }
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** The live subscription governing this subject, or null. */
|
|
89
|
+
async function findSubscription({ subjectType, subjectId } = {}) {
|
|
90
|
+
const db = getDb();
|
|
91
|
+
if (!db || !subjectType || !subjectId) return null;
|
|
92
|
+
return db.collection("account_subscriptions").findOne({
|
|
93
|
+
subject_type: subjectType,
|
|
94
|
+
subject_id: oid(subjectId),
|
|
95
|
+
status: { $in: LIVE_STATUSES },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve entitlement for one feature.
|
|
101
|
+
*
|
|
102
|
+
* Returns an OPEN result whenever anything is missing — no subscription, no plan row, no
|
|
103
|
+
* database — for the reason in the header.
|
|
104
|
+
*/
|
|
105
|
+
async function entitlementFor({
|
|
106
|
+
subjectType, subjectId, featureCode, feature = {}, identity = {},
|
|
107
|
+
bypass = false, now = new Date(),
|
|
108
|
+
} = {}) {
|
|
109
|
+
try {
|
|
110
|
+
// Master switch off ⇒ the same OPEN answer as "no subscription": allowed, unshaped,
|
|
111
|
+
// unmetered. Every caller already handles this, because it is the fail-open path.
|
|
112
|
+
if (!(await enabled())) return OPEN("subscription_disabled");
|
|
113
|
+
const db = getDb();
|
|
114
|
+
if (!db || !subjectType || !subjectId) return OPEN("no_subject");
|
|
115
|
+
|
|
116
|
+
const subscription = await findSubscription({ subjectType, subjectId });
|
|
117
|
+
if (!subscription) return resolveEntitlement({ subscription: null, featureCode, feature, bypass, now });
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Trial terms: while `trialing`, rows with cycle "trial" take precedence over the purchased
|
|
121
|
+
* cycle's — that is how a trial offers LESS than the paid plan without being a separate
|
|
122
|
+
* plan. No trial rows ⇒ the paid terms apply during trial, which is the
|
|
123
|
+
* time-limited-full-plan case.
|
|
124
|
+
*/
|
|
125
|
+
const cycles = subscription.status === "trialing"
|
|
126
|
+
? ["trial", subscription.cycle]
|
|
127
|
+
: [subscription.cycle];
|
|
128
|
+
/**
|
|
129
|
+
* Fetched together: the feature's own rows AND the plan's credit-allowance rows. A plan IS
|
|
130
|
+
* credits-mode when a SUBSCRIPTION.CREDITS row resolves — the mode is the data, so there is
|
|
131
|
+
* no flag to drift out of step with it.
|
|
132
|
+
*/
|
|
133
|
+
const fetched = await db.collection("plan_entitlements").find({
|
|
134
|
+
plan_code: subscription.plan_code,
|
|
135
|
+
cycle: { $in: cycles },
|
|
136
|
+
feature_code: featureCode === CREDITS_FEATURE_CODE
|
|
137
|
+
? featureCode
|
|
138
|
+
: { $in: [featureCode, CREDITS_FEATURE_CODE] },
|
|
139
|
+
}).toArray();
|
|
140
|
+
const pick = (code) => {
|
|
141
|
+
const mine = fetched.filter((r) => r.feature_code === code);
|
|
142
|
+
const trial = mine.filter((r) => r.cycle === "trial");
|
|
143
|
+
return subscription.status === "trialing" && trial.length
|
|
144
|
+
? trial
|
|
145
|
+
: mine.filter((r) => r.cycle === subscription.cycle);
|
|
146
|
+
};
|
|
147
|
+
const rows = pick(featureCode);
|
|
148
|
+
const creditRows = featureCode === CREDITS_FEATURE_CODE ? [] : pick(CREDITS_FEATURE_CODE);
|
|
149
|
+
const creditsMode = creditRows.length > 0 && feature.is_meterable;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* CREDITS MODE — the feature's own row says whether it is INCLUDED (and may override the
|
|
153
|
+
* cost); the guard runs against the shared credit allowance, priced per use.
|
|
154
|
+
*
|
|
155
|
+
* cost = plan row's credit_cost > service's registry credit_cost > 1
|
|
156
|
+
*
|
|
157
|
+
* The per-plan override is your "premium plans give more usage" lever: eCAS can cost 2 on
|
|
158
|
+
* Discover and 1 on Grow without any per-feature matrix returning through the back door.
|
|
159
|
+
*/
|
|
160
|
+
/**
|
|
161
|
+
* A per-feature BUDGET opts out of credits mode.
|
|
162
|
+
*
|
|
163
|
+
* `quota` is overloaded, and the two meanings need opposite handling: on VPD it is "12
|
|
164
|
+
* sessions a year" — a budget that must decrement and refuse at zero — while on stock tips it
|
|
165
|
+
* is "5 ideas per answer", a ceiling read on every request and never spent. Credits mode
|
|
166
|
+
* counts only the wallet, so a budget expressed that way silently never moved: Grow showed
|
|
167
|
+
* 12 VPD sessions that could be used forever.
|
|
168
|
+
*
|
|
169
|
+
* So a row with a real number, on a feature that does NOT shape a response, falls through to
|
|
170
|
+
* count mode and is governed by its own counter. Credits stay for what is priced in credits.
|
|
171
|
+
* `shapes_response` is the discriminator because it is a property of the FEATURE — whether it
|
|
172
|
+
* trims an answer or counts a use — not of the plan that sells it.
|
|
173
|
+
*/
|
|
174
|
+
const budgetRow = creditsMode ? resolveEntitlementRow(rows, featureCode, now) : null;
|
|
175
|
+
const isOwnBudget = !!budgetRow
|
|
176
|
+
&& budgetRow.unlocked !== false
|
|
177
|
+
&& feature.shapes_response !== true
|
|
178
|
+
&& budgetRow.quota !== null && budgetRow.quota !== undefined && budgetRow.quota !== ""
|
|
179
|
+
&& Number.isFinite(Number(budgetRow.quota));
|
|
180
|
+
|
|
181
|
+
if (creditsMode && !isOwnBudget) {
|
|
182
|
+
const svcRow = resolveEntitlementRow(rows, featureCode, now);
|
|
183
|
+
// Not named in a credits plan → the same fail-open NOT_IN_PLAN as count mode. A plan
|
|
184
|
+
// that forgot to include a feature is a config gap, not a paywall.
|
|
185
|
+
if (svcRow && svcRow.unlocked !== false) {
|
|
186
|
+
// UNSET falls through to the next layer; 0 does not — it is the plan saying "free".
|
|
187
|
+
const priced = (v) => (v === null || v === undefined || v === "" || !Number.isFinite(Number(v)) || Number(v) < 0
|
|
188
|
+
? null : Number(v));
|
|
189
|
+
const cost = priced(svcRow.credit_cost) ?? priced(feature.credit_cost) ?? 1;
|
|
190
|
+
|
|
191
|
+
let packBalance = 0;
|
|
192
|
+
try {
|
|
193
|
+
const bal = await db.collection("feature_credits").aggregate([
|
|
194
|
+
{ $match: {
|
|
195
|
+
subject_type: subjectType,
|
|
196
|
+
subject_id: oid(subjectId),
|
|
197
|
+
feature_code: CREDITS_FEATURE_CODE,
|
|
198
|
+
$expr: { $lt: ["$consumed", "$purchased"] },
|
|
199
|
+
$or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
|
|
200
|
+
} },
|
|
201
|
+
{ $group: { _id: null, n: { $sum: { $subtract: ["$purchased", "$consumed"] } } } },
|
|
202
|
+
]).toArray();
|
|
203
|
+
packBalance = bal[0]?.n || 0;
|
|
204
|
+
} catch (e) {
|
|
205
|
+
logger.warn(`[entitlement] credit-pack balance unreadable: ${e.message}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const creditRow = creditRows[0];
|
|
209
|
+
const period = periodKey(creditRow.period_granularity || "month", {
|
|
210
|
+
now, subscriptionId: subscription._id, periodStart: subscription.period_start,
|
|
211
|
+
});
|
|
212
|
+
const subjectKey = subjectKeyFor(creditRow.quota_scope, identity) || "";
|
|
213
|
+
const usage = await db.collection("subscription_usage").findOne({
|
|
214
|
+
subscription_id: subscription._id,
|
|
215
|
+
feature_code: CREDITS_FEATURE_CODE,
|
|
216
|
+
period,
|
|
217
|
+
subject_key: subjectKey,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const result = await resolveEntitlement({
|
|
221
|
+
subscription,
|
|
222
|
+
entitlementRows: creditRows,
|
|
223
|
+
featureCode: CREDITS_FEATURE_CODE,
|
|
224
|
+
feature: { is_meterable: true },
|
|
225
|
+
used: usage?.used || 0,
|
|
226
|
+
credits: packBalance,
|
|
227
|
+
identity, bypass, now, cost,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const effectiveCreditRow = creditRows[0]
|
|
231
|
+
? { ...creditRows[0], quota: result.quota, overage_policy: result.overage_policy || creditRows[0].overage_policy }
|
|
232
|
+
: null;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A CEILING survives credits mode.
|
|
236
|
+
*
|
|
237
|
+
* `limit` shapes a response — "your list shows 5" — and is read on every request without
|
|
238
|
+
* ever being spent. A PRICE is what a use costs. They are orthogonal, and the two live on
|
|
239
|
+
* different fields for exactly that reason.
|
|
240
|
+
*
|
|
241
|
+
* Without this line the credits branch returned the wallet's numbers and no `limit` at
|
|
242
|
+
* all, so a consumer reading `ent.limit` (NFD AI's stock-tips shaper does) saw nothing
|
|
243
|
+
* and applied no ceiling. A free plan promising five stock ideas silently served every
|
|
244
|
+
* one of them — the paywall looked configured and was not.
|
|
245
|
+
*
|
|
246
|
+
* Same UNSET-vs-0 care as count mode: null means "no ceiling", 0 means "show nothing".
|
|
247
|
+
*/
|
|
248
|
+
const rawCeiling = svcRow.quota;
|
|
249
|
+
const ceiling = rawCeiling === null || rawCeiling === undefined || rawCeiling === ""
|
|
250
|
+
? null
|
|
251
|
+
: Number(rawCeiling);
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
...result,
|
|
255
|
+
limit: ceiling !== null && Number.isFinite(ceiling) && ceiling >= 0 ? ceiling : null,
|
|
256
|
+
mode: "credits",
|
|
257
|
+
carriedSubjectType: subjectType,
|
|
258
|
+
carriedSubjectId: subjectId,
|
|
259
|
+
carriedSubscriptionId: subscription._id,
|
|
260
|
+
carriedPeriodStart: subscription.period_start || null,
|
|
261
|
+
carriedIdentity: identity,
|
|
262
|
+
carriedRow: effectiveCreditRow,
|
|
263
|
+
// consume() counts against THIS bucket, at THIS price — never recomputed.
|
|
264
|
+
carriedFeatureCode: CREDITS_FEATURE_CODE,
|
|
265
|
+
carriedCost: cost,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* À-la-carte credits — units bought singly. Summed across purchases that still have units
|
|
272
|
+
* left and have not expired. Read alongside usage because the resolver needs both to say
|
|
273
|
+
* whether the customer may proceed.
|
|
274
|
+
*/
|
|
275
|
+
let credits = 0;
|
|
276
|
+
if (feature.is_meterable || feature.single_usage_purchasable) {
|
|
277
|
+
// Guarded separately: credits are a top-up, and failing to read them must not take the
|
|
278
|
+
// quota check down with it. Losing a purchased unit is bad; silently ceasing to meter
|
|
279
|
+
// everyone because one collection is unreachable is worse.
|
|
280
|
+
try {
|
|
281
|
+
const bal = await db.collection("feature_credits").aggregate([
|
|
282
|
+
{
|
|
283
|
+
$match: {
|
|
284
|
+
subject_type: subjectType,
|
|
285
|
+
subject_id: oid(subjectId),
|
|
286
|
+
feature_code: featureCode,
|
|
287
|
+
$expr: { $lt: ["$consumed", "$purchased"] },
|
|
288
|
+
$or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
{ $group: { _id: null, n: { $sum: { $subtract: ["$purchased", "$consumed"] } } } },
|
|
292
|
+
]).toArray();
|
|
293
|
+
credits = bal[0]?.n || 0;
|
|
294
|
+
} catch (e) {
|
|
295
|
+
logger.warn(`[entitlement] credit balance unreadable for ${featureCode}: ${e.message}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Only read the counter when something could actually be metered — an unmetered feature
|
|
300
|
+
// should not cost a query on every request.
|
|
301
|
+
let used = 0;
|
|
302
|
+
const row = rows[0] || null;
|
|
303
|
+
if (feature.is_meterable && row) {
|
|
304
|
+
const period = periodKey(row.period_granularity || "month", { now, subscriptionId: subscription._id, periodStart: subscription.period_start });
|
|
305
|
+
const subjectKey = subjectKeyFor(row.quota_scope, identity) || "";
|
|
306
|
+
const usage = await db.collection("subscription_usage").findOne({
|
|
307
|
+
subscription_id: subscription._id,
|
|
308
|
+
feature_code: featureCode,
|
|
309
|
+
period,
|
|
310
|
+
subject_key: subjectKey,
|
|
311
|
+
});
|
|
312
|
+
used = usage?.used || 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const result = await resolveEntitlement({
|
|
316
|
+
subscription, entitlementRows: rows, featureCode, feature, used, credits, identity, bypass, now,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Carry what consume() needs, so it cannot recompute the period key and count against a
|
|
321
|
+
* different bucket than the one that was just checked.
|
|
322
|
+
*
|
|
323
|
+
* The carried row takes its ceiling from the RESOLVED result, not from the raw plan row —
|
|
324
|
+
* because a per-subject override changes the decision without changing the row it came
|
|
325
|
+
* from. Carrying the raw row meant consume guarded on the plan's quota while the check had
|
|
326
|
+
* already passed on the overridden one: a subject granted 5 was refused at 2, and it
|
|
327
|
+
* surfaced as a counter that had simply stopped moving.
|
|
328
|
+
*/
|
|
329
|
+
const effectiveRow = row
|
|
330
|
+
? { ...row, quota: result.quota, overage_policy: result.overage_policy || row.overage_policy }
|
|
331
|
+
: null;
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
...result,
|
|
335
|
+
carriedSubjectType: subjectType,
|
|
336
|
+
carriedSubjectId: subjectId,
|
|
337
|
+
carriedSubscriptionId: subscription._id,
|
|
338
|
+
carriedPeriodStart: subscription.period_start || null,
|
|
339
|
+
carriedIdentity: identity,
|
|
340
|
+
carriedRow: effectiveRow,
|
|
341
|
+
};
|
|
342
|
+
} catch (e) {
|
|
343
|
+
logger.warn(`[entitlement] resolve failed for ${featureCode}, allowing through: ${e.message}`);
|
|
344
|
+
return OPEN("resolve_error");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Per-service breakdown when a shared bucket pays — "where did my 500 credits go".
|
|
350
|
+
*
|
|
351
|
+
* Fire-and-forget and unguarded: it is reporting, not enforcement, and a failed attribution
|
|
352
|
+
* row must never undo a consumption the guard already allowed. Only written when the bucket
|
|
353
|
+
* differs from the feature (credits mode) — in count mode the guarded row IS the attribution.
|
|
354
|
+
*/
|
|
355
|
+
function recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now }) {
|
|
356
|
+
if (!db || bucketCode === featureCode) return;
|
|
357
|
+
try {
|
|
358
|
+
const row = entitlement?.carriedRow || {};
|
|
359
|
+
const period = periodKey(row.period_granularity || "month", {
|
|
360
|
+
now, subscriptionId: subId, periodStart: entitlement?.carriedPeriodStart,
|
|
361
|
+
});
|
|
362
|
+
const subjectKey = subjectKeyFor(row.quota_scope, ident) || "";
|
|
363
|
+
db.collection("subscription_usage").updateOne(
|
|
364
|
+
{ subscription_id: subId, feature_code: featureCode, period, subject_key: subjectKey },
|
|
365
|
+
{ $inc: { used: n, credits_spent: units }, $setOnInsert: { attribution: true } },
|
|
366
|
+
{ upsert: true },
|
|
367
|
+
).catch((e) => logger.warn(`[entitlement] attribution write failed: ${e.message}`));
|
|
368
|
+
} catch (e) {
|
|
369
|
+
logger.warn(`[entitlement] attribution skipped: ${e.message}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Consume one unit — race-free by construction.
|
|
375
|
+
*
|
|
376
|
+
* The `used < quota` test lives inside the FILTER, so Mongo decides atomically whether the
|
|
377
|
+
* increment may happen; matchedCount 0 means the ceiling was already reached. A read-then-write
|
|
378
|
+
* would let two concurrent requests both pass a 5-of-5 check and land a 6th.
|
|
379
|
+
*
|
|
380
|
+
* Only `block` enforces the ceiling — under `warn` the count still rises, and that record is the
|
|
381
|
+
* evidence for whether a quota is right before anyone is ever refused.
|
|
382
|
+
*
|
|
383
|
+
* Accepts either the result of `entitlementFor` (preferred — it carries the exact bucket that
|
|
384
|
+
* was checked) or the explicit pieces, so both existing call shapes keep working.
|
|
385
|
+
*/
|
|
386
|
+
async function consume({
|
|
387
|
+
entitlement, featureCode, n = 1, now = new Date(),
|
|
388
|
+
subscriptionId, row, identity, periodStart,
|
|
389
|
+
} = {}) {
|
|
390
|
+
try {
|
|
391
|
+
const db = getDb();
|
|
392
|
+
const subId = subscriptionId || entitlement?.carriedSubscriptionId;
|
|
393
|
+
const useRow = row || entitlement?.carriedRow;
|
|
394
|
+
const ident = identity || entitlement?.carriedIdentity || {};
|
|
395
|
+
// An unmetered feature has nothing to count; saying so is not a failure.
|
|
396
|
+
/**
|
|
397
|
+
* Checked before anything else, including the not-metered short-circuits: "the domain is
|
|
398
|
+
* off" is a truer answer than "this feature is not metered", and it has to hold even when
|
|
399
|
+
* the caller passed nothing useful. Returning `consumed: false` rather than throwing keeps
|
|
400
|
+
* callers on the path they already have for "the counter did not move".
|
|
401
|
+
*/
|
|
402
|
+
if (!(await enabled())) return { consumed: false, reason: "subscription_disabled" };
|
|
403
|
+
|
|
404
|
+
if (entitlement && !entitlement.metered) return { consumed: false, reason: "not_metered" };
|
|
405
|
+
if (!db || !subId || !useRow) return { consumed: false, reason: "not_metered" };
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Credits mode: the BUCKET is the plan's credit allowance, and one action costs `cost`
|
|
409
|
+
* units. Both were decided by the resolve that authorised this call — recomputing either
|
|
410
|
+
* here could draw from a bucket nobody checked, at a price nobody quoted.
|
|
411
|
+
*/
|
|
412
|
+
const bucketCode = entitlement?.carriedFeatureCode || featureCode;
|
|
413
|
+
const carried = Number(entitlement?.carriedCost);
|
|
414
|
+
const units = n * (Number.isFinite(carried) && carried >= 0 ? carried : 1);
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* A free action inside a credits plan: authorised, but there is no balance to move. Writing
|
|
418
|
+
* a zero increment would still create the row and make it look like something was spent.
|
|
419
|
+
*/
|
|
420
|
+
if (units === 0) return { consumed: true, free: true };
|
|
421
|
+
|
|
422
|
+
// The SAME bucket that entitlementFor just checked — recomputing it from different inputs
|
|
423
|
+
// would count against a period nobody validated.
|
|
424
|
+
const start = periodStart !== undefined ? periodStart : entitlement?.carriedPeriodStart;
|
|
425
|
+
/**
|
|
426
|
+
* Spending a purchased credit instead of the plan's allowance.
|
|
427
|
+
*
|
|
428
|
+
* `usingCredit` was decided by the same resolve that authorised this call, so the two cannot
|
|
429
|
+
* disagree about which bucket was checked and which is being drawn down. The guard
|
|
430
|
+
* `consumed < purchased` sits INSIDE the filter for the same reason the quota test does:
|
|
431
|
+
* Mongo decides atomically, so two concurrent calls cannot both spend the last unit.
|
|
432
|
+
*
|
|
433
|
+
* Oldest purchase first — a credit with an expiry should be used before one without.
|
|
434
|
+
*/
|
|
435
|
+
if (entitlement?.usingCredit) {
|
|
436
|
+
const hit = await db.collection("feature_credits").findOneAndUpdate(
|
|
437
|
+
{
|
|
438
|
+
subject_type: entitlement.carriedSubjectType,
|
|
439
|
+
subject_id: oid(entitlement.carriedSubjectId),
|
|
440
|
+
feature_code: bucketCode,
|
|
441
|
+
/**
|
|
442
|
+
* The WHOLE cost must fit in one pack row, decided atomically. A 2-credit action
|
|
443
|
+
* against a pack with 1 left is refused rather than split across purchases —
|
|
444
|
+
* splitting would need a transaction across rows, and the boundary case (the last
|
|
445
|
+
* unit of one pack) is not worth that machinery. The refusal falls through to the
|
|
446
|
+
* plan counter, which answers honestly.
|
|
447
|
+
*/
|
|
448
|
+
$expr: { $lte: [{ $add: ["$consumed", units] }, "$purchased"] },
|
|
449
|
+
$or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
|
|
450
|
+
},
|
|
451
|
+
{ $inc: { consumed: units } },
|
|
452
|
+
{ sort: { expires_at: 1, createdAt: 1 } },
|
|
453
|
+
);
|
|
454
|
+
if (hit) {
|
|
455
|
+
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
456
|
+
return { consumed: true, fromCredit: true };
|
|
457
|
+
}
|
|
458
|
+
// No credit left after all — fall through and let the plan counter answer, which may well
|
|
459
|
+
// refuse. Better than silently succeeding on a balance that just went to zero.
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const period = periodKey(useRow.period_granularity || "month", { now, subscriptionId: subId, periodStart: start });
|
|
463
|
+
const subjectKey = subjectKeyFor(useRow.quota_scope, ident) || "";
|
|
464
|
+
const filter = {
|
|
465
|
+
subscription_id: subId,
|
|
466
|
+
feature_code: bucketCode,
|
|
467
|
+
period,
|
|
468
|
+
subject_key: subjectKey,
|
|
469
|
+
};
|
|
470
|
+
const policy = useRow.overage_policy || "block";
|
|
471
|
+
const capped = policy === "block" && useRow.quota != null;
|
|
472
|
+
|
|
473
|
+
// Unguarded (warn / unlimited): a plain upsert is right — the count rises either way, and
|
|
474
|
+
// that record is the evidence for whether a quota is set correctly before anyone is refused.
|
|
475
|
+
if (!capped) {
|
|
476
|
+
await db.collection("subscription_usage").updateOne(
|
|
477
|
+
filter, { $inc: { used: units }, $setOnInsert: { ...filter } }, { upsert: true },
|
|
478
|
+
);
|
|
479
|
+
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
480
|
+
return { consumed: true };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Capped: try the GUARDED update first, without upsert. Cost-aware — the whole cost must
|
|
484
|
+
// fit ($lte quota - units is exactly $lt quota when units is 1), and a balance never goes
|
|
485
|
+
// negative on an in-flight action.
|
|
486
|
+
const guarded = { ...filter, used: { $lte: Number(useRow.quota) - units } };
|
|
487
|
+
const hit = await db.collection("subscription_usage").updateOne(guarded, { $inc: { used: units } });
|
|
488
|
+
if (hit.matchedCount > 0) {
|
|
489
|
+
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
490
|
+
return { consumed: true };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// No match means either "no row yet" (first use this period) or "quota reached". Distinguish
|
|
494
|
+
// by INSERTING — the unique index makes that safe under concurrency: exactly one racer wins
|
|
495
|
+
// and the loser's duplicate-key means the row now exists, i.e. the quota case.
|
|
496
|
+
//
|
|
497
|
+
// Doing this as an upsert on the guarded filter instead would rely on a duplicate-key
|
|
498
|
+
// EXCEPTION to mean "quota exceeded" — correct by accident, and unreadable.
|
|
499
|
+
try {
|
|
500
|
+
// First use this period must ALSO fit — an allowance smaller than one action's cost is
|
|
501
|
+
// refused on action one, not discovered at minus-something.
|
|
502
|
+
if (Number(useRow.quota) - units < 0) return { consumed: false, reason: "quota_exceeded" };
|
|
503
|
+
await db.collection("subscription_usage").insertOne({ ...filter, used: units, createdAt: new Date() });
|
|
504
|
+
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
505
|
+
return { consumed: true };
|
|
506
|
+
} catch (e) {
|
|
507
|
+
if (e?.code === 11000) return { consumed: false, reason: "quota_exceeded" };
|
|
508
|
+
throw e;
|
|
509
|
+
}
|
|
510
|
+
} catch (e) {
|
|
511
|
+
logger.warn(`[entitlement] consume failed for ${featureCode}: ${e.message}`);
|
|
512
|
+
// Fail open on infrastructure error: never refuse a request because our counter misbehaved.
|
|
513
|
+
return { consumed: false, reason: "error" };
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Give a subject the default plan, if there is one and they have none.
|
|
519
|
+
*
|
|
520
|
+
* A distributor who has never bought anything currently resolves to "no subscription", which
|
|
521
|
+
* fails OPEN — so today every distributor silently has unlimited everything. A default plan is
|
|
522
|
+
* how that becomes deliberate: a real package with real limits, assigned automatically, so the
|
|
523
|
+
* system's answer to "what may this person do" stops being "we never decided".
|
|
524
|
+
*
|
|
525
|
+
* ── Idempotent, and safe to call from anywhere ───────────────────────────────────────────────
|
|
526
|
+
* Returns the existing subscription untouched if one is live. That matters because the natural
|
|
527
|
+
* call site is distributor creation, and distributors get created down several paths (self
|
|
528
|
+
* onboarding, admin, draft approval, bulk import) — a provisioner that could double-assign would
|
|
529
|
+
* have to be wired carefully into exactly one of them, which is how coverage gaps happen. This
|
|
530
|
+
* one can be called from all of them, or twice, or after the fact.
|
|
531
|
+
*
|
|
532
|
+
* ── Never fatal ──────────────────────────────────────────────────────────────────────────────
|
|
533
|
+
* Returns null on any failure rather than throwing. Callers create distributors; a subscription
|
|
534
|
+
* that could not be provisioned must never be the reason a distributor does not exist. The gap
|
|
535
|
+
* is recoverable by calling this again — a failed creation is not.
|
|
536
|
+
*
|
|
537
|
+
* @param {Object} p
|
|
538
|
+
* @param {string} p.subjectType "distributor" | "client"
|
|
539
|
+
* @param {string} p.subjectId
|
|
540
|
+
* @param {string} [p.accountId]
|
|
541
|
+
* @param {Date} [p.now]
|
|
542
|
+
* @returns {Promise<Object|null>} the subscription (existing or new), or null
|
|
543
|
+
*/
|
|
544
|
+
async function provisionDefault({ subjectType, subjectId, accountId = null, now = new Date() } = {}) {
|
|
545
|
+
try {
|
|
546
|
+
// No auto-provisioning while the domain is off, whatever the catalogue says. This is the
|
|
547
|
+
// one that would otherwise write rows into production on the first distributor created.
|
|
548
|
+
if (!(await enabled())) return null;
|
|
549
|
+
|
|
550
|
+
const db = getDb();
|
|
551
|
+
if (!db || !subjectType || !subjectId) return null;
|
|
552
|
+
|
|
553
|
+
const existing = await findSubscription({ subjectType, subjectId });
|
|
554
|
+
if (existing) return existing;
|
|
555
|
+
|
|
556
|
+
// The default plan is a property of the CATALOGUE, not of this code — so which package new
|
|
557
|
+
// distributors get, and for how long, is changed by editing a plan rather than deploying.
|
|
558
|
+
const plan = await db.collection("plan_catalog").findOne({
|
|
559
|
+
default_for: subjectType,
|
|
560
|
+
status: "active",
|
|
561
|
+
});
|
|
562
|
+
if (!plan) return null;
|
|
563
|
+
|
|
564
|
+
const days = Number(plan.default_duration_days) || 0;
|
|
565
|
+
const start = new Date(now);
|
|
566
|
+
const end = new Date(now);
|
|
567
|
+
if (days > 0) end.setDate(end.getDate() + days);
|
|
568
|
+
// No duration means an open-ended default tier; period_end null reads as "does not lapse".
|
|
569
|
+
const periodEnd = days > 0 ? end : null;
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Status is `active`, not `trialing`.
|
|
573
|
+
*
|
|
574
|
+
* This is a real package on real terms, not a taste of a bigger one — and `trialing` would
|
|
575
|
+
* make trial-cycle entitlement rows take precedence, quietly applying terms nobody wrote for
|
|
576
|
+
* this plan. The trial mechanism stays available for plans that actually want it.
|
|
577
|
+
*/
|
|
578
|
+
const doc = {
|
|
579
|
+
account_id: accountId ? oid(accountId) : null,
|
|
580
|
+
subject_type: subjectType,
|
|
581
|
+
subject_id: oid(subjectId),
|
|
582
|
+
plan_code: plan.plan_code,
|
|
583
|
+
cycle: plan.default_cycle || "monthly",
|
|
584
|
+
status: "active",
|
|
585
|
+
period_start: start,
|
|
586
|
+
period_end: periodEnd,
|
|
587
|
+
provisioned_default: true, // so a report can tell "given" from "bought"
|
|
588
|
+
createdAt: new Date(),
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
await db.collection("account_subscriptions").insertOne(doc);
|
|
593
|
+
} catch (e) {
|
|
594
|
+
// Two creation paths racing on the same subject. The partial unique index on a live
|
|
595
|
+
// subscription is what makes this safe: one wins, and the loser reads the winner's row
|
|
596
|
+
// rather than creating a duplicate.
|
|
597
|
+
if (e?.code === 11000) return findSubscription({ subjectType, subjectId });
|
|
598
|
+
throw e;
|
|
599
|
+
}
|
|
600
|
+
return doc;
|
|
601
|
+
} catch (e) {
|
|
602
|
+
logger.warn(`[entitlement] default provisioning failed for ${subjectType} ${subjectId}: ${e.message}`);
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return { findSubscription, entitlementFor, consume, provisionDefault };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export default { createEntitlementStore };
|