@i4e/invest4edu-access-core 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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 };