@i4e/invest4edu-access-core 0.20.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.
Files changed (2) hide show
  1. package/package.json +3 -2
  2. package/src/credits.js +128 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.20.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
+ }