@i4e/invest4edu-access-core 0.32.0 → 0.34.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/src/proration.js CHANGED
@@ -1,155 +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 };
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 };
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Portfolio report audience — who is a report FOR, and which sections may it show.
3
+ *
4
+ * A product review (Aditya, Manoj, Rozy) decided the portfolio report must show different things
5
+ * to different audiences: a partner's client and an employee-serviced client see a reduced,
6
+ * B2C-toned view (health score and storytelling, no recommendations), while an adviser working
7
+ * their own book keeps the full report.
8
+ *
9
+ * ── The audience is a property of the CLIENT, never the viewer ────────────────────────────────
10
+ * One RM opens a partner's client and a direct client in the same session, and the InvestValue
11
+ * mobile app is always a client viewer while its users are clients of BOTH types. So the answer
12
+ * is derived from the client record and obeyed identically by every renderer — NFD web, the
13
+ * native mobile screens, and anything that comes later.
14
+ *
15
+ * ── Why this lives in the package ─────────────────────────────────────────────────────────────
16
+ * The same rule is needed in v1 (which fronts the eCAS registry) and v2 (which mints the PFA
17
+ * subject token and answers PFA's callbacks). Two copies of a classification rule is how the two
18
+ * halves drift into disagreeing about what a partner's client may see — and a drift here is not a
19
+ * cosmetic bug: it decides what a client is shown about their own money.
20
+ *
21
+ * ── PURE, like the rest of this package ───────────────────────────────────────────────────────
22
+ * No Mongoose, no I/O. The two identity lookups are INJECTED, because they are the one part that
23
+ * genuinely differs: v1 has Mongoose models, v2 reads raw collections, and a script may have
24
+ * neither. Injecting them keeps the decision testable without a database and keeps the package
25
+ * free of every repo's model graph — the same contract `access-resolver` and `route-features`
26
+ * already follow.
27
+ *
28
+ * ── FAIL OPEN, ALWAYS ─────────────────────────────────────────────────────────────────────────
29
+ * An unknown audience, an unreadable registry, a thrown lookup — all resolve to the full report.
30
+ * A gating bug that blanks a working report is worse than one that shows too much, and this code
31
+ * sits in front of a screen clients already use.
32
+ *
33
+ * ── What this is NOT ──────────────────────────────────────────────────────────────────────────
34
+ * Presentation, not confidentiality. The sections withheld are the client's own portfolio data
35
+ * shown in less depth, and the map reaches the renderers through channels a caller can influence.
36
+ * It must never become the only thing standing between a viewer and data they may not see; if a
37
+ * genuinely must-not-see section is ever added, it needs enforcement at the data source instead.
38
+ * Decision recorded 10 Sep 2026.
39
+ *
40
+ * Spec: IV-CodingAgent/specs/access-management/SPEC-portfolio-report-audience.md
41
+ */
42
+
43
+ /** Audiences. Surface-neutral: every renderer maps these to its own components. */
44
+ export const AUDIENCE = Object.freeze({
45
+ PARTNER: "partner",
46
+ RM_E1: "rm_e1",
47
+ DIRECT: "direct",
48
+ FULL: "full",
49
+ });
50
+
51
+ /**
52
+ * Section keys. Deliberately named for WHAT they are, not for the component that renders them,
53
+ * because independent renderers consume this: nfd-ui gates React components, the mobile app
54
+ * filters its tab array. A key named after either one would read as foreign in the other.
55
+ *
56
+ * `healthScore` has no entry: the gauge is the one thing every audience keeps, so it is never
57
+ * gated. Making it configurable would invite a state where the report shows nothing at all.
58
+ */
59
+ export const SECTION_KEYS = Object.freeze([
60
+ "actions", // the 3-step action plan / recommendations
61
+ "phc", // portfolio health-check detail body
62
+ "chat", // AI chat
63
+ "performance", // performance / gains
64
+ "keyInsights",
65
+ "fundAnalysis", // the fund analysis table
66
+ "scoreBreakdownFunds", // underlying funds inside the score breakdown
67
+ "advisorCta", // "call your advisor" prompt
68
+ ]);
69
+
70
+ /** Everything on. The answer whenever we cannot prove a narrower one is correct. */
71
+ export const ALL_VISIBLE = Object.freeze({
72
+ actions: true,
73
+ phc: true,
74
+ chat: true,
75
+ performance: true,
76
+ keyInsights: true,
77
+ fundAnalysis: true,
78
+ scoreBreakdownFunds: true,
79
+ advisorCta: false, // opt-in, not part of "everything"
80
+ fundDetail: "full",
81
+ tone: "adviser",
82
+ });
83
+
84
+ /** The reduced view. Partner and RM-E1 clients get exactly this; direct adds the CTA. */
85
+ const REDUCED = Object.freeze({
86
+ actions: false,
87
+ phc: false,
88
+ chat: false,
89
+ performance: false,
90
+ keyInsights: false,
91
+ fundAnalysis: false,
92
+ scoreBreakdownFunds: false,
93
+ advisorCta: false,
94
+ fundDetail: "minimal",
95
+ tone: "b2c",
96
+ });
97
+
98
+ /**
99
+ * Defaults encode the review notes. They are the FALLBACK, not the source of truth — the registry
100
+ * overrides them per feature. They exist so the resolver still answers correctly before the
101
+ * registry rows are seeded, and when the registry cannot be read.
102
+ */
103
+ export const AUDIENCE_DEFAULTS = Object.freeze({
104
+ [AUDIENCE.FULL]: ALL_VISIBLE,
105
+
106
+ // "Don't show for Partner clients & RM clients where RM is E1. Actions will not come.
107
+ // Portfolio Health Score component — don't show; only show Health Score, no recommendations.
108
+ // Hide the AI chatbot. Remove Key / Fund Analysis. Hide Performance. Less fund details.
109
+ // Score breakdown — remove underlying fund. Language B2C / neutral."
110
+ [AUDIENCE.PARTNER]: REDUCED,
111
+
112
+ // Same treatment as a partner client — the review grouped them in one line.
113
+ [AUDIENCE.RM_E1]: REDUCED,
114
+
115
+ // "Direct Clients — call advisor to get details to know the plan. Same storytelling."
116
+ [AUDIENCE.DIRECT]: Object.freeze({ ...REDUCED, advisorCta: true }),
117
+ });
118
+
119
+ const E1_TYPE_DISTRIBUTOR = "distributor";
120
+
121
+ /** A lookup that was not supplied, or threw, proves nothing. Never let it throw upward. */
122
+ async function safeAsk(fn, id) {
123
+ if (typeof fn !== "function") return false;
124
+ try {
125
+ return !!(await fn(id));
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Classify a client record into an audience.
133
+ *
134
+ * `e1_type` is a HINT and is treated as one — the same rule `shapePersonFilterOptions` follows,
135
+ * for the same reason: on devpoc 66 rows carry a type that disagrees with the collection the id is
136
+ * actually in (29 on dev), and those rows predate the field being declared. So both collections
137
+ * are consulted and the hint only decides which answer wins when both could be true. Trusting the
138
+ * enum bare would misclassify exactly those rows — and misclassifying is how a partner's client
139
+ * ends up seeing an adviser's report.
140
+ *
141
+ * @param {{ distributor_id?: *, e1?: *, e1_type?: string }} client
142
+ * @param {Object} [lookups]
143
+ * @param {(id: *) => Promise<boolean>} [lookups.isEmployee] does this id name an employee-backed user?
144
+ * @param {(id: *) => Promise<boolean>} [lookups.isDistributor] does this id name a real distributor?
145
+ * @returns {Promise<'partner'|'rm_e1'|'direct'>}
146
+ */
147
+ export async function classifyAudience(client, lookups = {}) {
148
+ if (!client) return AUDIENCE.DIRECT;
149
+
150
+ // A client sitting in a partner's book is a partner client, whatever E1 says. This is the
151
+ // strongest signal we have and it is a plain field — house clients were moved to direct by
152
+ // nulling it (dedup, Aug 2026), so `null` genuinely means "not a partner's".
153
+ if (client.distributor_id) return AUDIENCE.PARTNER;
154
+
155
+ const { e1 } = client;
156
+ if (!e1) return AUDIENCE.DIRECT;
157
+
158
+ // Ask both, then let the hint break the tie — never the other way round.
159
+ const [employee, distributor] = await Promise.all([
160
+ safeAsk(lookups.isEmployee, e1),
161
+ safeAsk(lookups.isDistributor, e1),
162
+ ]);
163
+
164
+ if (employee && distributor) {
165
+ return client.e1_type === E1_TYPE_DISTRIBUTOR ? AUDIENCE.PARTNER : AUDIENCE.RM_E1;
166
+ }
167
+ if (employee) return AUDIENCE.RM_E1;
168
+ if (distributor) return AUDIENCE.PARTNER;
169
+
170
+ // An E1 resolving to nobody (deleted user, another account's distributor) is exactly the
171
+ // bad-data case above. Fall back to the reduced-but-safe audience rather than to `full`.
172
+ return AUDIENCE.DIRECT;
173
+ }
174
+
175
+ /**
176
+ * Merge registry overrides over the audience defaults.
177
+ *
178
+ * The registry is the source of truth for WHICH sections exist and whether each is on; the
179
+ * defaults answer while the rows are unseeded. `loadOverrides` is injected rather than imported so
180
+ * this module stays free of the access models — and so a caller with no registry at all (the
181
+ * mobile path has no access snapshot: no roles, no grants) can skip the read entirely without a
182
+ * second code path.
183
+ *
184
+ * @param {string} audience
185
+ * @param {(audience: string) => Promise<Object|null>} [loadOverrides]
186
+ */
187
+ export async function resolveSections(audience, loadOverrides) {
188
+ const base = AUDIENCE_DEFAULTS[audience] || ALL_VISIBLE;
189
+ if (typeof loadOverrides !== "function") return { ...base };
190
+ try {
191
+ const overrides = await loadOverrides(audience);
192
+ return overrides && typeof overrides === "object" ? { ...base, ...overrides } : { ...base };
193
+ } catch {
194
+ return { ...base }; // registry unreadable → the audience default still applies
195
+ }
196
+ }
197
+
198
+ /**
199
+ * The one entry point callers use: classify, resolve, and never throw.
200
+ *
201
+ * @param {Object} client client-ish record (distributor_id, e1, e1_type)
202
+ * @param {Object} [opts]
203
+ * @param {(id: *) => Promise<boolean>} [opts.isEmployee]
204
+ * @param {(id: *) => Promise<boolean>} [opts.isDistributor]
205
+ * @param {(audience: string) => Promise<Object|null>} [opts.loadOverrides]
206
+ * @returns {Promise<{ audience: string, sections: Object }>}
207
+ */
208
+ export async function resolveReportAudience(client, opts = {}) {
209
+ try {
210
+ const audience = await classifyAudience(client, opts);
211
+ return { audience, sections: await resolveSections(audience, opts.loadOverrides) };
212
+ } catch {
213
+ // Fail open. Every caller renders a report from this; none of them should render nothing.
214
+ return { audience: AUDIENCE.FULL, sections: { ...ALL_VISIBLE } };
215
+ }
216
+ }
217
+
218
+ export default {
219
+ AUDIENCE,
220
+ SECTION_KEYS,
221
+ ALL_VISIBLE,
222
+ AUDIENCE_DEFAULTS,
223
+ classifyAudience,
224
+ resolveSections,
225
+ resolveReportAudience,
226
+ };