@i4e/invest4edu-access-core 0.17.3 → 0.20.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.17.3",
3
+ "version": "0.20.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": {
@@ -18,7 +18,8 @@
18
18
  "./grid-schema": "./src/grid-schema.js",
19
19
  "./route-features": "./src/route-features.js",
20
20
  "./subscription-lifecycle": "./src/subscription-lifecycle.js",
21
- "./entitlement-store": "./src/entitlement-store.js"
21
+ "./entitlement-store": "./src/entitlement-store.js",
22
+ "./proration": "./src/proration.js"
22
23
  },
23
24
  "scripts": {
24
25
  "test": "node --test test/"
@@ -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
 
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Proration — what the unused part of a running term is worth when someone upgrades.
3
+ *
4
+ * Without it, an upgrade STACKS: the new term is appended to the current end date, so the days
5
+ * already paid for on the smaller plan sit behind the new one, unusable at the new tier. The
6
+ * customer quietly loses that value and nothing on screen says so.
7
+ *
8
+ * The POLICY is configuration, not code — mode, basis, rounding, cap and scope all arrive from
9
+ * the caller (platform default < plan < service), so changing how proration works is an edit
10
+ * rather than a deploy. This module is only the arithmetic, kept pure so it can be tested
11
+ * without a database, a clock or a subscription.
12
+ *
13
+ * ── The rules that are NOT configurable, because getting them wrong invents money ────────────
14
+ * · credit can never exceed what was actually paid for the current term
15
+ * · credit can never exceed the new plan's price (a purchase must not become negative)
16
+ * · rounding is toward the CUSTOMER paying more, never less — we do not credit value that was
17
+ * not paid for
18
+ * · a term that has not started, or has already ended, is worth nothing
19
+ */
20
+
21
+ /** Every knob, with the conservative answer as the default. */
22
+ export const PRORATION_DEFAULTS = {
23
+ /**
24
+ * none — today's behaviour: no credit, the new term stacks on the old end date
25
+ * daily — unused days ÷ total days
26
+ * monthly — whole unused months only; a part-month counts for nothing
27
+ * points — no cash credit; the unused value is returned as reward points instead
28
+ */
29
+ mode: "none",
30
+ /** Ceiling on the credit as a percentage of the new plan's price. 100 = up to the full price. */
31
+ cap_percent: 100,
32
+ /** Apply when the PLAN changes, and/or when only the billing cycle changes. */
33
+ on_plan_change: true,
34
+ on_cycle_change: false,
35
+ };
36
+
37
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
38
+
39
+ /** Whole days between two instants, never negative. */
40
+ function daysBetween(from, to) {
41
+ const ms = new Date(to).getTime() - new Date(from).getTime();
42
+ return ms <= 0 ? 0 : Math.floor(ms / MS_PER_DAY);
43
+ }
44
+
45
+ /** Whole months remaining — a part-month is worth nothing under `monthly`. */
46
+ function wholeMonthsBetween(from, to) {
47
+ const a = new Date(from);
48
+ const b = new Date(to);
49
+ if (b <= a) return 0;
50
+ let months = (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
51
+ if (b.getDate() < a.getDate()) months -= 1;
52
+ return months > 0 ? months : 0;
53
+ }
54
+
55
+ /**
56
+ * Merge policy layers. Later wins, and only for keys it actually specifies — a plan that sets
57
+ * only `mode` must not silently reset the cap someone configured globally.
58
+ */
59
+ export function resolveProrationPolicy(...layers) {
60
+ const out = { ...PRORATION_DEFAULTS };
61
+ for (const layer of layers) {
62
+ if (!layer || typeof layer !== "object") continue;
63
+ for (const k of Object.keys(PRORATION_DEFAULTS)) {
64
+ if (layer[k] !== undefined && layer[k] !== null) out[k] = layer[k];
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * What the unused remainder of the current term is worth, in PAISE.
72
+ *
73
+ * @param {object} a
74
+ * @param {number} a.paidAmount what was paid for the CURRENT term (paise, ex-tax)
75
+ * @param {Date} a.periodStart when the current term began
76
+ * @param {Date} a.periodEnd when it ends (null/absent ⇒ open-ended ⇒ nothing to prorate)
77
+ * @param {Date} a.now the moment of the change
78
+ * @param {number} a.newPlanPrice price of what they are moving to (paise, ex-tax) — the cap
79
+ * @param {boolean} a.isCycleChange true when the plan is unchanged and only the cycle differs
80
+ * @param {object} a.policy resolved policy (see resolveProrationPolicy)
81
+ * @returns {{credit:number, as_points:boolean, reason:string, days_remaining:number,
82
+ * days_total:number, mode:string}}
83
+ */
84
+ export function prorate({
85
+ paidAmount = 0,
86
+ periodStart,
87
+ periodEnd,
88
+ now = new Date(),
89
+ newPlanPrice = 0,
90
+ isCycleChange = false,
91
+ policy = PRORATION_DEFAULTS,
92
+ } = {}) {
93
+ const p = resolveProrationPolicy(policy);
94
+ const nil = (reason) => ({
95
+ credit: 0, as_points: false, reason, mode: p.mode, days_remaining: 0, days_total: 0,
96
+ });
97
+
98
+ if (p.mode === "none") return nil("proration is off");
99
+ if (isCycleChange && !p.on_cycle_change) return nil("cycle changes are not prorated");
100
+ if (!isCycleChange && !p.on_plan_change) return nil("plan changes are not prorated");
101
+
102
+ // An open-ended term (the provisioned default) has no remainder to value: nothing was paid for
103
+ // a period that does not end.
104
+ if (!periodEnd) return nil("term has no end date");
105
+ if (!(paidAmount > 0)) return nil("nothing was paid for the current term");
106
+
107
+ const daysTotal = daysBetween(periodStart, periodEnd);
108
+ const daysRemaining = daysBetween(now, periodEnd);
109
+ if (daysTotal <= 0) return nil("term length is zero");
110
+ if (daysRemaining <= 0) return nil("term has already ended");
111
+
112
+ let credit;
113
+ if (p.mode === "monthly") {
114
+ const monthsRemaining = wholeMonthsBetween(now, periodEnd);
115
+ const monthsTotal = wholeMonthsBetween(periodStart, periodEnd);
116
+ if (monthsTotal <= 0 || monthsRemaining <= 0) {
117
+ return { ...nil("less than a whole month remains"), days_remaining: daysRemaining, days_total: daysTotal };
118
+ }
119
+ credit = Math.floor((paidAmount * monthsRemaining) / monthsTotal);
120
+ } else {
121
+ // daily, and the basis for `points` too — the difference is only in how it is returned.
122
+ credit = Math.floor((paidAmount * daysRemaining) / daysTotal);
123
+ }
124
+
125
+ // Never more than was paid. Floating-point and clock skew both make this reachable.
126
+ credit = Math.min(credit, paidAmount);
127
+
128
+ /**
129
+ * The cap. Without it, a long unused term can exceed the new plan's price and the purchase
130
+ * goes to zero or negative — the customer upgrades for free, or we owe them money for taking
131
+ * a bigger plan. Applies to the cash credit only; points are a separate liability that does
132
+ * not have to fit inside this purchase.
133
+ */
134
+ if (p.mode !== "points") {
135
+ const ceiling = Math.floor((newPlanPrice * Math.max(0, Math.min(100, p.cap_percent))) / 100);
136
+ if (credit > ceiling) {
137
+ credit = ceiling;
138
+ }
139
+ }
140
+
141
+ if (credit <= 0) return { ...nil("nothing to credit"), days_remaining: daysRemaining, days_total: daysTotal };
142
+
143
+ return {
144
+ credit,
145
+ as_points: p.mode === "points",
146
+ reason: p.mode === "points"
147
+ ? `${daysRemaining} unused day(s) returned as points`
148
+ : `${daysRemaining} of ${daysTotal} day(s) unused`,
149
+ mode: p.mode,
150
+ days_remaining: daysRemaining,
151
+ days_total: daysTotal,
152
+ };
153
+ }
154
+
155
+ export default { prorate, resolveProrationPolicy, PRORATION_DEFAULTS };
@@ -70,6 +70,14 @@ export const SUBSCRIPTION_EVENT_TYPES = Object.freeze([
70
70
  // Consumption that happened outside the software — a webinar attended, a VPD session held.
71
71
  // There is no request behind it, so this event is the only record of who said it happened.
72
72
  "usage_recorded",
73
+ /**
74
+ * The refund conversation, recorded as it happens rather than reconstructed afterwards.
75
+ *
76
+ * A refund is the one flow where WHO ASKED and WHO AGREED both matter later — a credit note
77
+ * shows money went back but not that anyone approved it. `refund_requested` is the customer's
78
+ * ask, `refund_rejected` closes it with a reason, and `refunded` is the money actually moving.
79
+ */
80
+ "refund_requested", "refund_approved", "refund_rejected", "refunded",
73
81
  ]);
74
82
 
75
83
  export default { SUBSCRIPTION_STATUSES, LIVE_STATUSES, TRANSITIONS, canTransition, SUBSCRIPTION_EVENT_TYPES };