@i4e/invest4edu-access-core 0.17.3 → 0.19.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.19.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/"
@@ -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 };