@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.
- package/package.json +3 -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.
|
|
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
|
+
}
|