@i4e/invest4edu-access-core 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,7 +19,8 @@
19
19
  "./route-features": "./src/route-features.js",
20
20
  "./subscription-lifecycle": "./src/subscription-lifecycle.js",
21
21
  "./entitlement-store": "./src/entitlement-store.js",
22
- "./proration": "./src/proration.js"
22
+ "./proration": "./src/proration.js",
23
+ "./credits": "./src/credits.js"
23
24
  },
24
25
  "scripts": {
25
26
  "test": "node --test test/"
package/src/credits.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Credits — what happens to an unused allowance when a term ends.
3
+ *
4
+ * A credits plan hands out an allowance per term (500 credits a month) and every service spends
5
+ * from it at its own price. At the term boundary the counter resets, and the question nobody can
6
+ * avoid answering is what the unspent remainder was worth. Silence is itself an answer — the
7
+ * default today is that it evaporates, which is fine as a policy and indefensible as an accident.
8
+ *
9
+ * ── Rollover is a POLICY, resolved in layers ─────────────────────────────────────────────────
10
+ * platform default < plan. Same shape and same merge rule as proration, because they are the
11
+ * same kind of decision: what unused value is worth when the ground moves under it.
12
+ *
13
+ * ── The rules that are NOT configurable, because getting them wrong invents credits ──────────
14
+ * · you can never roll more than went unused — the cap trims, it never tops up
15
+ * · an unlimited allowance rolls nothing: there was no scarcity to carry forward
16
+ * · rounding is DOWN, so a rollover never hands out a credit that was not left over
17
+ * · rolled credits expire; carrying them forever turns a monthly allowance into a savings
18
+ * account and makes the liability unbounded. They lapse at the end of the term they land in
19
+ *
20
+ * Kept pure — no database, no clock, no subscription — so the arithmetic is testable on its own.
21
+ */
22
+
23
+ /** Every knob, with the conservative answer as the default. */
24
+ export const CREDITS_DEFAULTS = {
25
+ /**
26
+ * none — the remainder lapses at the term boundary (today's behaviour)
27
+ * full — everything unused carries into the next term
28
+ * capped — carries, but no more than `cap_percent` of the allowance
29
+ */
30
+ rollover_mode: "none",
31
+ /** Ceiling on what may roll, as a percentage of the term's allowance. Only used by `capped`. */
32
+ rollover_cap_percent: 50,
33
+ /**
34
+ * How long rolled credits survive, in terms. 1 = they must be used in the term they land in.
35
+ * Rolled credits never roll again regardless — carrying a carry compounds into a balance that
36
+ * no one budgeted for.
37
+ */
38
+ rollover_expires_in_terms: 1,
39
+ };
40
+
41
+ /**
42
+ * Merge policy layers. Later wins, and only for keys it actually specifies — a plan that sets
43
+ * only the mode must not silently reset a cap someone configured globally.
44
+ */
45
+ export function resolveCreditsPolicy(...layers) {
46
+ const out = { ...CREDITS_DEFAULTS };
47
+ for (const layer of layers) {
48
+ if (!layer || typeof layer !== "object") continue;
49
+ for (const k of Object.keys(CREDITS_DEFAULTS)) {
50
+ if (layer[k] !== undefined && layer[k] !== null) out[k] = layer[k];
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /**
57
+ * How many credits carry into the next term.
58
+ *
59
+ * @param {object} a
60
+ * @param {number|null} a.quota the term's allowance. null = unlimited ⇒ nothing to carry
61
+ * @param {number} a.used credits spent this term
62
+ * @param {object} a.policy resolved policy (see resolveCreditsPolicy)
63
+ * @returns {{units:number, reason:string, unused:number, cap:number|null, mode:string}}
64
+ */
65
+ export function computeRollover({ quota, used = 0, policy = CREDITS_DEFAULTS } = {}) {
66
+ const p = resolveCreditsPolicy(policy);
67
+ const mode = p.rollover_mode;
68
+ const nil = (reason) => ({ units: 0, reason, unused: 0, cap: null, mode });
69
+
70
+ if (mode === "none") return nil("rollover is off");
71
+ // Unlimited means the allowance never constrained anything, so there is no remainder to carry.
72
+ // Rolling "infinity minus what you used" is not a number, and any finite stand-in is invented.
73
+ if (quota == null) return nil("allowance is unlimited — nothing to carry");
74
+
75
+ const allowance = Number(quota);
76
+ if (!Number.isFinite(allowance) || allowance <= 0) return nil("no allowance this term");
77
+
78
+ const spent = Math.max(0, Number(used) || 0);
79
+ // Overage can push `used` past the quota under a warn policy; a negative remainder is not a debt.
80
+ const unused = Math.max(0, allowance - spent);
81
+ if (unused <= 0) return { units: 0, reason: "allowance fully used", unused: 0, cap: null, mode };
82
+
83
+ if (mode === "full") {
84
+ return { units: Math.floor(unused), reason: "carried in full", unused, cap: null, mode };
85
+ }
86
+
87
+ if (mode === "capped") {
88
+ const pct = Math.max(0, Math.min(100, Number(p.rollover_cap_percent) || 0));
89
+ const cap = Math.floor((allowance * pct) / 100);
90
+ const units = Math.min(Math.floor(unused), cap);
91
+ return {
92
+ units,
93
+ reason: units < unused ? `capped at ${pct}% of the allowance` : "carried in full (under the cap)",
94
+ unused,
95
+ cap,
96
+ mode,
97
+ };
98
+ }
99
+
100
+ // An unrecognised mode is a config error. Carrying nothing is the safe reading of it — the
101
+ // alternative invents a balance from a typo.
102
+ return nil(`unknown rollover mode "${mode}"`);
103
+ }
104
+
105
+ /**
106
+ * When rolled credits lapse: the end of the term they can be used in.
107
+ *
108
+ * Needs the length of the term they are landing in, which the caller knows and this module
109
+ * deliberately does not. A term with no end (open-ended subscription) yields null — nothing to
110
+ * expire against, and inventing a date would silently destroy paid-for value.
111
+ */
112
+ export function rolloverExpiryDate({ nextPeriodStart, nextPeriodEnd, policy = CREDITS_DEFAULTS } = {}) {
113
+ const p = resolveCreditsPolicy(policy);
114
+ if (!nextPeriodEnd) return null;
115
+ const end = new Date(nextPeriodEnd);
116
+ if (Number.isNaN(end.getTime())) return null;
117
+
118
+ const terms = Math.max(1, Math.floor(Number(p.rollover_expires_in_terms) || 1));
119
+ if (terms === 1) return end;
120
+
121
+ // More than one term: extend by the length of the term they land in, which is the only measure
122
+ // of "a term" available here. Without a start we cannot know that length, so we keep the single
123
+ // term rather than guess — under-granting is recoverable, over-granting is not.
124
+ const start = nextPeriodStart ? new Date(nextPeriodStart) : null;
125
+ if (!start || Number.isNaN(start.getTime()) || start >= end) return end;
126
+ const termMs = end.getTime() - start.getTime();
127
+ return new Date(end.getTime() + termMs * (terms - 1));
128
+ }
@@ -33,6 +33,16 @@ export const SUBSCRIPTION_STATUSES = Object.freeze([
33
33
  */
34
34
  export const QUOTA_TIMEZONE = "Asia/Kolkata";
35
35
 
36
+ /**
37
+ * The synthetic feature that carries a credits-mode plan's allowance.
38
+ *
39
+ * Credits mode needed no new machinery precisely because the allowance is an ordinary
40
+ * entitlement row on this code: it inherits cycle columns, effective-date versioning, trial
41
+ * precedence, as-of resolution and the race-free counter. A plan IS credits-mode when a row for
42
+ * this code resolves — there is no separate flag to drift out of step with the data.
43
+ */
44
+ export const CREDITS_FEATURE_CODE = "SUBSCRIPTION.CREDITS";
45
+
36
46
  /** Reasons an entitlement decision can carry. Stable strings — they surface in APIs and logs. */
37
47
  export const ENTITLEMENT_REASONS = Object.freeze({
38
48
  OK: "ok",
@@ -169,6 +179,7 @@ export const METER_CLIENT_DEF = Object.freeze({
169
179
 
170
180
  export default {
171
181
  PERIOD_GRANULARITIES,
182
+ CREDITS_FEATURE_CODE,
172
183
  QUOTA_SCOPES,
173
184
  OVERAGE_POLICIES,
174
185
  SUBSCRIPTION_STATUSES,
@@ -23,7 +23,8 @@
23
23
  * outage into a customer-visible one. Absence is far more likely to be our gap than a customer
24
24
  * genuinely owning nothing.
25
25
  */
26
- import { resolveEntitlement, periodKey, subjectKeyFor } from "./entitlement.js";
26
+ import { resolveEntitlement, resolveEntitlementRow, periodKey, subjectKeyFor } from "./entitlement.js";
27
+ import { CREDITS_FEATURE_CODE } from "./entitlement-schema.js";
27
28
  import { LIVE_STATUSES } from "./subscription-lifecycle.js";
28
29
 
29
30
  /** Every failure path returns this, so callers never have to branch on "did the lookup work". */
@@ -96,15 +97,105 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
96
97
  const cycles = subscription.status === "trialing"
97
98
  ? ["trial", subscription.cycle]
98
99
  : [subscription.cycle];
100
+ /**
101
+ * Fetched together: the feature's own rows AND the plan's credit-allowance rows. A plan IS
102
+ * credits-mode when a SUBSCRIPTION.CREDITS row resolves — the mode is the data, so there is
103
+ * no flag to drift out of step with it.
104
+ */
99
105
  const fetched = await db.collection("plan_entitlements").find({
100
106
  plan_code: subscription.plan_code,
101
107
  cycle: { $in: cycles },
102
- feature_code: featureCode,
108
+ feature_code: featureCode === CREDITS_FEATURE_CODE
109
+ ? featureCode
110
+ : { $in: [featureCode, CREDITS_FEATURE_CODE] },
103
111
  }).toArray();
104
- const trialRows = fetched.filter((r) => r.cycle === "trial");
105
- const rows = subscription.status === "trialing" && trialRows.length
106
- ? trialRows
107
- : fetched.filter((r) => r.cycle === subscription.cycle);
112
+ const pick = (code) => {
113
+ const mine = fetched.filter((r) => r.feature_code === code);
114
+ const trial = mine.filter((r) => r.cycle === "trial");
115
+ return subscription.status === "trialing" && trial.length
116
+ ? trial
117
+ : mine.filter((r) => r.cycle === subscription.cycle);
118
+ };
119
+ const rows = pick(featureCode);
120
+ const creditRows = featureCode === CREDITS_FEATURE_CODE ? [] : pick(CREDITS_FEATURE_CODE);
121
+ const creditsMode = creditRows.length > 0 && feature.is_meterable;
122
+
123
+ /**
124
+ * CREDITS MODE — the feature's own row says whether it is INCLUDED (and may override the
125
+ * cost); the guard runs against the shared credit allowance, priced per use.
126
+ *
127
+ * cost = plan row's credit_cost > service's registry credit_cost > 1
128
+ *
129
+ * The per-plan override is your "premium plans give more usage" lever: eCAS can cost 2 on
130
+ * Discover and 1 on Grow without any per-feature matrix returning through the back door.
131
+ */
132
+ if (creditsMode) {
133
+ const svcRow = resolveEntitlementRow(rows, featureCode, now);
134
+ // Not named in a credits plan → the same fail-open NOT_IN_PLAN as count mode. A plan
135
+ // that forgot to include a feature is a config gap, not a paywall.
136
+ if (svcRow && svcRow.unlocked !== false) {
137
+ const cost = Number(svcRow.credit_cost) > 0
138
+ ? Number(svcRow.credit_cost)
139
+ : (Number(feature.credit_cost) > 0 ? Number(feature.credit_cost) : 1);
140
+
141
+ let packBalance = 0;
142
+ try {
143
+ const bal = await db.collection("feature_credits").aggregate([
144
+ { $match: {
145
+ subject_type: subjectType,
146
+ subject_id: oid(subjectId),
147
+ feature_code: CREDITS_FEATURE_CODE,
148
+ $expr: { $lt: ["$consumed", "$purchased"] },
149
+ $or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
150
+ } },
151
+ { $group: { _id: null, n: { $sum: { $subtract: ["$purchased", "$consumed"] } } } },
152
+ ]).toArray();
153
+ packBalance = bal[0]?.n || 0;
154
+ } catch (e) {
155
+ logger.warn(`[entitlement] credit-pack balance unreadable: ${e.message}`);
156
+ }
157
+
158
+ const creditRow = creditRows[0];
159
+ const period = periodKey(creditRow.period_granularity || "month", {
160
+ now, subscriptionId: subscription._id, periodStart: subscription.period_start,
161
+ });
162
+ const subjectKey = subjectKeyFor(creditRow.quota_scope, identity) || "";
163
+ const usage = await db.collection("subscription_usage").findOne({
164
+ subscription_id: subscription._id,
165
+ feature_code: CREDITS_FEATURE_CODE,
166
+ period,
167
+ subject_key: subjectKey,
168
+ });
169
+
170
+ const result = await resolveEntitlement({
171
+ subscription,
172
+ entitlementRows: creditRows,
173
+ featureCode: CREDITS_FEATURE_CODE,
174
+ feature: { is_meterable: true },
175
+ used: usage?.used || 0,
176
+ credits: packBalance,
177
+ identity, bypass, now, cost,
178
+ });
179
+
180
+ const effectiveCreditRow = creditRows[0]
181
+ ? { ...creditRows[0], quota: result.quota, overage_policy: result.overage_policy || creditRows[0].overage_policy }
182
+ : null;
183
+
184
+ return {
185
+ ...result,
186
+ mode: "credits",
187
+ carriedSubjectType: subjectType,
188
+ carriedSubjectId: subjectId,
189
+ carriedSubscriptionId: subscription._id,
190
+ carriedPeriodStart: subscription.period_start || null,
191
+ carriedIdentity: identity,
192
+ carriedRow: effectiveCreditRow,
193
+ // consume() counts against THIS bucket, at THIS price — never recomputed.
194
+ carriedFeatureCode: CREDITS_FEATURE_CODE,
195
+ carriedCost: cost,
196
+ };
197
+ }
198
+ }
108
199
 
109
200
  /**
110
201
  * À-la-carte credits — units bought singly. Summed across purchases that still have units
@@ -184,6 +275,31 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
184
275
  }
185
276
  }
186
277
 
278
+ /**
279
+ * Per-service breakdown when a shared bucket pays — "where did my 500 credits go".
280
+ *
281
+ * Fire-and-forget and unguarded: it is reporting, not enforcement, and a failed attribution
282
+ * row must never undo a consumption the guard already allowed. Only written when the bucket
283
+ * differs from the feature (credits mode) — in count mode the guarded row IS the attribution.
284
+ */
285
+ function recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now }) {
286
+ if (!db || bucketCode === featureCode) return;
287
+ try {
288
+ const row = entitlement?.carriedRow || {};
289
+ const period = periodKey(row.period_granularity || "month", {
290
+ now, subscriptionId: subId, periodStart: entitlement?.carriedPeriodStart,
291
+ });
292
+ const subjectKey = subjectKeyFor(row.quota_scope, ident) || "";
293
+ db.collection("subscription_usage").updateOne(
294
+ { subscription_id: subId, feature_code: featureCode, period, subject_key: subjectKey },
295
+ { $inc: { used: n, credits_spent: units }, $setOnInsert: { attribution: true } },
296
+ { upsert: true },
297
+ ).catch((e) => logger.warn(`[entitlement] attribution write failed: ${e.message}`));
298
+ } catch (e) {
299
+ logger.warn(`[entitlement] attribution skipped: ${e.message}`);
300
+ }
301
+ }
302
+
187
303
  /**
188
304
  * Consume one unit — race-free by construction.
189
305
  *
@@ -210,6 +326,14 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
210
326
  if (entitlement && !entitlement.metered) return { consumed: false, reason: "not_metered" };
211
327
  if (!db || !subId || !useRow) return { consumed: false, reason: "not_metered" };
212
328
 
329
+ /**
330
+ * Credits mode: the BUCKET is the plan's credit allowance, and one action costs `cost`
331
+ * units. Both were decided by the resolve that authorised this call — recomputing either
332
+ * here could draw from a bucket nobody checked, at a price nobody quoted.
333
+ */
334
+ const bucketCode = entitlement?.carriedFeatureCode || featureCode;
335
+ const units = n * (Number(entitlement?.carriedCost) > 0 ? Number(entitlement.carriedCost) : 1);
336
+
213
337
  // The SAME bucket that entitlementFor just checked — recomputing it from different inputs
214
338
  // would count against a period nobody validated.
215
339
  const start = periodStart !== undefined ? periodStart : entitlement?.carriedPeriodStart;
@@ -228,14 +352,24 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
228
352
  {
229
353
  subject_type: entitlement.carriedSubjectType,
230
354
  subject_id: oid(entitlement.carriedSubjectId),
231
- feature_code: featureCode,
232
- $expr: { $lt: ["$consumed", "$purchased"] },
355
+ feature_code: bucketCode,
356
+ /**
357
+ * The WHOLE cost must fit in one pack row, decided atomically. A 2-credit action
358
+ * against a pack with 1 left is refused rather than split across purchases —
359
+ * splitting would need a transaction across rows, and the boundary case (the last
360
+ * unit of one pack) is not worth that machinery. The refusal falls through to the
361
+ * plan counter, which answers honestly.
362
+ */
363
+ $expr: { $lte: [{ $add: ["$consumed", units] }, "$purchased"] },
233
364
  $or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
234
365
  },
235
- { $inc: { consumed: n } },
366
+ { $inc: { consumed: units } },
236
367
  { sort: { expires_at: 1, createdAt: 1 } },
237
368
  );
238
- if (hit) return { consumed: true, fromCredit: true };
369
+ if (hit) {
370
+ recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
371
+ return { consumed: true, fromCredit: true };
372
+ }
239
373
  // No credit left after all — fall through and let the plan counter answer, which may well
240
374
  // refuse. Better than silently succeeding on a balance that just went to zero.
241
375
  }
@@ -244,7 +378,7 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
244
378
  const subjectKey = subjectKeyFor(useRow.quota_scope, ident) || "";
245
379
  const filter = {
246
380
  subscription_id: subId,
247
- feature_code: featureCode,
381
+ feature_code: bucketCode,
248
382
  period,
249
383
  subject_key: subjectKey,
250
384
  };
@@ -255,15 +389,21 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
255
389
  // that record is the evidence for whether a quota is set correctly before anyone is refused.
256
390
  if (!capped) {
257
391
  await db.collection("subscription_usage").updateOne(
258
- filter, { $inc: { used: n }, $setOnInsert: { ...filter } }, { upsert: true },
392
+ filter, { $inc: { used: units }, $setOnInsert: { ...filter } }, { upsert: true },
259
393
  );
394
+ recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
260
395
  return { consumed: true };
261
396
  }
262
397
 
263
- // Capped: try the GUARDED update first, without upsert.
264
- const guarded = { ...filter, used: { $lt: Number(useRow.quota) } };
265
- const hit = await db.collection("subscription_usage").updateOne(guarded, { $inc: { used: n } });
266
- if (hit.matchedCount > 0) return { consumed: true };
398
+ // Capped: try the GUARDED update first, without upsert. Cost-aware — the whole cost must
399
+ // fit ($lte quota - units is exactly $lt quota when units is 1), and a balance never goes
400
+ // negative on an in-flight action.
401
+ const guarded = { ...filter, used: { $lte: Number(useRow.quota) - units } };
402
+ const hit = await db.collection("subscription_usage").updateOne(guarded, { $inc: { used: units } });
403
+ if (hit.matchedCount > 0) {
404
+ recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
405
+ return { consumed: true };
406
+ }
267
407
 
268
408
  // No match means either "no row yet" (first use this period) or "quota reached". Distinguish
269
409
  // by INSERTING — the unique index makes that safe under concurrency: exactly one racer wins
@@ -272,7 +412,11 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
272
412
  // Doing this as an upsert on the guarded filter instead would rely on a duplicate-key
273
413
  // EXCEPTION to mean "quota exceeded" — correct by accident, and unreadable.
274
414
  try {
275
- await db.collection("subscription_usage").insertOne({ ...filter, used: n, createdAt: new Date() });
415
+ // First use this period must ALSO fit — an allowance smaller than one action's cost is
416
+ // refused on action one, not discovered at minus-something.
417
+ if (Number(useRow.quota) - units < 0) return { consumed: false, reason: "quota_exceeded" };
418
+ await db.collection("subscription_usage").insertOne({ ...filter, used: units, createdAt: new Date() });
419
+ recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
276
420
  return { consumed: true };
277
421
  } catch (e) {
278
422
  if (e?.code === 11000) return { consumed: false, reason: "quota_exceeded" };
@@ -134,6 +134,12 @@ function withOverride(row, overrides = [], featureCode) {
134
134
  export function resolveEntitlement({
135
135
  subscription, entitlementRows = [], featureCode, feature = {},
136
136
  used = 0, credits = 0, identity = {}, bypass = false, now = new Date(),
137
+ /**
138
+ * Units this ONE action costs. 1 for count-mode (used + 1 ≤ quota is exactly the old
139
+ * used < quota), the service's credit cost when a credits-mode plan is paying. Threading it
140
+ * through here is what lets one resolver serve both modes instead of a parallel copy.
141
+ */
142
+ cost = 1,
137
143
  } = {}) {
138
144
  const base = {
139
145
  quota: null, used, remaining: null, period: null, subject_key: null,
@@ -177,8 +183,11 @@ export function resolveEntitlement({
177
183
  const granularity = row.period_granularity || "month";
178
184
  const period = periodKey(granularity, { now, subscriptionId: subscription._id, periodStart: subscription.period_start });
179
185
  const subject_key = subjectKeyFor(row.quota_scope, identity);
186
+ const unitCost = Number(cost) > 0 ? Number(cost) : 1;
180
187
  const planRemaining = Math.max(0, Number(row.quota) - Number(used || 0));
181
- const withinQuota = Number(used || 0) < Number(row.quota);
188
+ // Cost-aware: the action is allowed only if the WHOLE cost fits. Refusing at balance < cost is
189
+ // the agreed rule — a balance never goes negative on an in-flight action.
190
+ const withinQuota = Number(used || 0) + unitCost <= Number(row.quota);
182
191
  const policy = row.overage_policy || "block";
183
192
 
184
193
  /**
@@ -192,7 +201,7 @@ export function resolveEntitlement({
192
201
  * Credits do not reset with the period — they were paid for, so they last until used.
193
202
  */
194
203
  const creditBalance = Math.max(0, Number(credits || 0));
195
- const usingCredit = !withinQuota && creditBalance > 0;
204
+ const usingCredit = !withinQuota && creditBalance >= unitCost;
196
205
  const remaining = planRemaining + creditBalance;
197
206
 
198
207
  return {
@@ -210,6 +219,7 @@ export function resolveEntitlement({
210
219
  period,
211
220
  subject_key,
212
221
  metered: true,
222
+ cost: unitCost,
213
223
  };
214
224
  }
215
225