@i4e/invest4edu-access-core 0.36.0 → 0.38.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.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Sides, profiles and tagging — who a client's business belongs to.
3
+ *
4
+ * ── Vocabulary ───────────────────────────────────────────────────────────────────────────────
5
+ * profile — one `distributorclients` record. A person has at most one `main` and one
6
+ * `corporate` profile.
7
+ * mapping — one entry of `profile.distributor_mappings[]`: a distributor (or direct, when
8
+ * `distributor_id` is null) that does business with this profile. Exactly one is
9
+ * `is_primary`.
10
+ * side key — a stable string naming a side: the distributor id as a string, or `DIRECT_SIDE`.
11
+ * House distributors (InvestValue's own) collapse to `DIRECT_SIDE`.
12
+ *
13
+ * ── Why a side KEY rather than the raw id ────────────────────────────────────────────────────
14
+ * Records carry `distributor_id` as an ObjectId, a string, null, or a house distributor's id,
15
+ * depending on which path wrote them. Comparing those directly is how the same side gets counted
16
+ * twice. Every comparison in this module goes through `sideKeyOf`.
17
+ *
18
+ * Pure: no I/O. House distributor ids are resolved by the caller (from the configured codes) and
19
+ * passed in, because only the backend can look codes up.
20
+ */
21
+ import { PROFILE_TYPES, isEvaluated } from "./settings.js";
22
+
23
+ export const DIRECT_SIDE = "direct";
24
+
25
+ /** Why a record was tagged to the side it was. Logged, and stored on the record in audit mode. */
26
+ export const TAG_REASONS = Object.freeze({
27
+ PLACER: "placer",
28
+ MF_EXCLUSIVE: "mf_exclusive",
29
+ CLIENT_APP: "client_app",
30
+ });
31
+
32
+ /**
33
+ * @param {*} distributorId ObjectId | string | null | undefined
34
+ * @param {{ houseDistributorIds?: Iterable<string> }} ctx
35
+ */
36
+ export function sideKeyOf(distributorId, { houseDistributorIds } = {}) {
37
+ if (distributorId === null || distributorId === undefined || distributorId === "") return DIRECT_SIDE;
38
+ const key = String(distributorId);
39
+ if (houseDistributorIds && new Set([...houseDistributorIds].map(String)).has(key)) return DIRECT_SIDE;
40
+ return key;
41
+ }
42
+
43
+ /** The profile type a distributor's client gets. No distributor → main. */
44
+ export function profileTypeFor(distributor, settings) {
45
+ const types = settings?.corporate_distributor_types ?? [];
46
+ return distributor && types.includes(distributor.distributor_type) ? PROFILE_TYPES.CORPORATE : PROFILE_TYPES.MAIN;
47
+ }
48
+
49
+ /** The primary mapping, or a synthetic one from `distributor_id` for records not yet backfilled. */
50
+ export function primaryMappingOf(profile) {
51
+ const mappings = Array.isArray(profile?.distributor_mappings) ? profile.distributor_mappings : [];
52
+ const primary = mappings.find((m) => m && m.is_primary);
53
+ if (primary) return primary;
54
+ return { distributor_id: profile?.distributor_id ?? null, is_primary: true, primary_locked: false, synthetic: true };
55
+ }
56
+
57
+ /** The primary side key of a profile. */
58
+ export function primarySideOf(profile, ctx) {
59
+ return sideKeyOf(primaryMappingOf(profile).distributor_id, ctx);
60
+ }
61
+
62
+ /** Every side a profile does business with, primary first, de-duplicated. */
63
+ export function sidesOf(profile, ctx) {
64
+ const keys = [primarySideOf(profile, ctx)];
65
+ for (const m of profile?.distributor_mappings ?? []) {
66
+ if (!m || m.status === "inactive") continue;
67
+ const key = sideKeyOf(m.distributor_id, ctx);
68
+ if (!keys.includes(key)) keys.push(key);
69
+ }
70
+ return keys;
71
+ }
72
+
73
+ /** The mapping for a side, if the profile has one. */
74
+ export function mappingForSide(profile, sideKey, ctx) {
75
+ if (primarySideOf(profile, ctx) === sideKey) return primaryMappingOf(profile);
76
+ return (profile?.distributor_mappings ?? []).find((m) => m && sideKeyOf(m.distributor_id, ctx) === sideKey) ?? null;
77
+ }
78
+
79
+ /** True when the product code is configured as a mutual fund. */
80
+ export function isMfProduct(productCode, settings) {
81
+ return Boolean(productCode) && (settings?.mf?.product_codes ?? []).includes(productCode);
82
+ }
83
+
84
+ /**
85
+ * Which side a new order is tagged to.
86
+ *
87
+ * Order of precedence, each behind its own mode:
88
+ * 1. MF exclusivity — MF always belongs to the primary (`mf_primary_stamp`).
89
+ * 2. Client app — the client never picks; the primary gets it (`app_orders_primary`).
90
+ * 3. Otherwise — the side of whoever placed it.
91
+ *
92
+ * Returns the decision for audit logging AND the side to apply. In audit mode `applied` is the
93
+ * placer's side (today's behaviour) while `decided` carries what enforce would do.
94
+ *
95
+ * @param {{ profile: object, placerSide: string, productCode?: string, isClientApp?: boolean,
96
+ * settings: object, ctx?: object }} input
97
+ * @returns {{ decided: string, reason: string, applied: string, changed: boolean }}
98
+ */
99
+ export function orderSideFor({ profile, placerSide, productCode, isClientApp = false, settings, ctx }) {
100
+ const primary = primarySideOf(profile, ctx);
101
+ const placer = placerSide ?? primary;
102
+ let decided = placer;
103
+ let reason = TAG_REASONS.PLACER;
104
+ let mode = null;
105
+
106
+ if (isMfProduct(productCode, settings) && settings?.mf?.exclusive_to_primary && isEvaluated(settings, "mf_primary_stamp")) {
107
+ decided = primary;
108
+ reason = TAG_REASONS.MF_EXCLUSIVE;
109
+ mode = "mf_primary_stamp";
110
+ } else if (isClientApp && settings?.app_orders?.tag_to_primary && isEvaluated(settings, "app_orders_primary")) {
111
+ decided = primary;
112
+ reason = TAG_REASONS.CLIENT_APP;
113
+ mode = "app_orders_primary";
114
+ }
115
+
116
+ const enforce = mode ? settings.modes[mode] === "enforce" : true;
117
+ const applied = enforce ? decided : placer;
118
+ return { decided, reason, applied, changed: decided !== placer };
119
+ }
120
+
121
+ /**
122
+ * Should the first qualifying order lock (or move) the primary?
123
+ *
124
+ * The primary is provisional until the first order. That order locks it:
125
+ * - to the order's side for ordinary products,
126
+ * - in place for MF (MF is tagged to the primary, so it can never move it).
127
+ * A locked primary never moves here; a referral (below) or an ops correction are the only other
128
+ * ways it changes.
129
+ *
130
+ * @returns {{ lock: boolean, newPrimarySide: string|null, moves: boolean }}
131
+ */
132
+ export function primaryLockFor({ profile, orderSide, orderStatus, productCode, settings, ctx }) {
133
+ const none = { lock: false, newPrimarySide: null, moves: false };
134
+ if (!settings?.primary?.lock_on_first_order || !isEvaluated(settings, "primary_lock")) return none;
135
+ if ((settings.primary.ignore_order_statuses ?? []).includes(String(orderStatus))) return none;
136
+ const primary = primaryMappingOf(profile);
137
+ if (primary.primary_locked) return none;
138
+ const current = sideKeyOf(primary.distributor_id, ctx);
139
+ const target = isMfProduct(productCode, settings) && settings.mf?.exclusive_to_primary ? current : orderSide;
140
+ return { lock: true, newPrimarySide: target, moves: target !== current };
141
+ }
142
+
143
+ /**
144
+ * The primary a converting lead should get. A distributor referral wins and is locked; anything
145
+ * else keeps the lead's own distributor as a provisional primary.
146
+ *
147
+ * @returns {{ primarySide: string, locked: boolean, secondarySide: string|null, reason: string }}
148
+ */
149
+ export function primaryFromLead({ lead, settings, ctx }) {
150
+ const leadSide = sideKeyOf(lead?.distributor_id, ctx);
151
+ const referralTypes = settings?.primary?.referral_types ?? [];
152
+ const referred = settings?.primary?.referral_sets_primary
153
+ && lead?.referred_by_id
154
+ && referralTypes.includes(lead?.referred_by_type);
155
+ if (!referred) return { primarySide: leadSide, locked: false, secondarySide: null, reason: "lead_distributor" };
156
+ const referrerSide = sideKeyOf(lead.referred_by_id, ctx);
157
+ return {
158
+ primarySide: referrerSide,
159
+ locked: true,
160
+ secondarySide: referrerSide !== leadSide ? leadSide : null,
161
+ reason: "referral",
162
+ };
163
+ }
164
+
165
+ /**
166
+ * The profile's new `distributor_id` and `distributor_mappings` after the primary is locked on a
167
+ * side (and moved there if it is not already the primary). Pure — the caller writes it.
168
+ * A side that is not yet mapped is added as the new primary; the old primary stays as a
169
+ * secondary mapping, so nobody loses the client.
170
+ *
171
+ * @returns {{ distributor_id: *, distributor_mappings: object[] }}
172
+ */
173
+ export function withPrimaryLocked(profile, { newPrimarySide, newPrimaryDistributorId = null, at = new Date(), via = "order", ctx } = {}) {
174
+ const mappings = Array.isArray(profile?.distributor_mappings) && profile.distributor_mappings.length
175
+ ? profile.distributor_mappings.map((m) => ({ ...m }))
176
+ : [{ ...primaryMappingOf(profile), synthetic: undefined }];
177
+ let target = mappings.find((m) => sideKeyOf(m.distributor_id, ctx) === newPrimarySide);
178
+ if (!target) {
179
+ target = {
180
+ distributor_id: newPrimaryDistributorId,
181
+ is_primary: false,
182
+ primary_locked: false,
183
+ via,
184
+ mapped_at: at,
185
+ mapped_by: null,
186
+ first_business_at: at,
187
+ rm_id: null,
188
+ dist_emp_rm_id: null,
189
+ department_id: null,
190
+ merged_from_client_id: null,
191
+ status: "active",
192
+ };
193
+ mappings.push(target);
194
+ }
195
+ for (const m of mappings) {
196
+ m.is_primary = m === target;
197
+ if (m === target) {
198
+ m.primary_locked = true;
199
+ m.first_business_at = m.first_business_at ?? at;
200
+ }
201
+ delete m.synthetic;
202
+ }
203
+ return { distributor_id: target.distributor_id ?? null, distributor_mappings: mappings };
204
+ }
package/src/index.js CHANGED
@@ -35,6 +35,13 @@ export {
35
35
  ROLE_TYPE_PRIORITY,
36
36
  pickPortalRoleName,
37
37
  } from "./portal-roles.js";
38
+ export {
39
+ MOBILE_LOGIN_SOURCES,
40
+ CLIENT_APP_LOGIN_SOURCES,
41
+ PARTNER_APP_HATS,
42
+ APP_BUNDLE_ID_PATTERN,
43
+ appBundleIdOf,
44
+ } from "./mobile-app-identity.js";
38
45
  export {
39
46
  DEFAULT_ACCESS_FLAGS,
40
47
  ACCESS_FLAG_ENV_NAMES,
@@ -82,3 +89,7 @@ export {
82
89
  applyRelabel,
83
90
  resolveColumns,
84
91
  } from "./visible-when.js";
92
+
93
+ // ── Client-profile model (two profiles per person, sides, access level, field policy) ─────────
94
+ // Namespaced: its vocabulary (MODES, PROFILE_TYPES, …) is too generic to flatten into the root.
95
+ export * as clientProfile from "./client-profile/index.js";
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Which mobile app a login came from, and what it may record — @i4e/invest4edu-access-core.
3
+ *
4
+ * Two apps sign in to the same backends: the partner app (NeoFinDesk) and the client app
5
+ * (InvestValue AI). A person's hat names the app: a client hat is the client app, every other
6
+ * hat the partner app. Both backends read these to decide which hat a mobile login flags and
7
+ * what bundle id its session row keeps.
8
+ *
9
+ * ── Why this lives in the package rather than in each backend ─────────────────────────
10
+ * nfd-api-node declared these in src/api/common/mobileAppIdentity.js and nfd-api-node-v2 in
11
+ * services/user/user.js, held together by tests pinning each copy's literals (BRI-1263). The
12
+ * sets are fixed by the domain — the mobile platforms and the entity kinds — so they are
13
+ * constants, not reference data; but two copies of a constant are still two definitions.
14
+ */
15
+ import { ENTITY } from './entity-status.js';
16
+
17
+ /**
18
+ * Login / creation `source` values that mean "a mobile app" without naming which one. The
19
+ * partner app sends these, and so did the client app before it had its own values.
20
+ */
21
+ export const MOBILE_LOGIN_SOURCES = new Set(['MOBILE_ANDROID', 'MOBILE_IOS', 'MOBILE_APP']);
22
+
23
+ /** Sources only the client app (InvestValue AI) sends: they name the app, not just "mobile". */
24
+ export const CLIENT_APP_LOGIN_SOURCES = new Set(['IV_CLIENT_APP_IOS', 'IV_CLIENT_APP_ANDROID']);
25
+
26
+ /** Hats the partner app (NeoFinDesk) signs in as — every hat except the client one. */
27
+ export const PARTNER_APP_HATS = Object.values(ENTITY).filter((t) => t !== ENTITY.CLIENT);
28
+
29
+ /**
30
+ * A bundle id / package name: iOS reverse-DNS (letters, digits, `.`, `-`) and Android
31
+ * (letters, digits, `.`, `_`), at most 255 characters. Anything else — markup, a newline, a
32
+ * spreadsheet formula — is not an app identifier and is not stored.
33
+ */
34
+ export const APP_BUNDLE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/;
35
+
36
+ /**
37
+ * The calling app's bundle id for a session row, or null.
38
+ *
39
+ * A login whose source names a non-mobile channel (WEB, PARTNER_API, …) stores null whatever
40
+ * it sends. A login with NO source keeps it: the client app sends none on its OTP, MPIN and
41
+ * biometric logins, and those are exactly the logins the bundle id is for. Checked for shape
42
+ * only, not against an allowlist — a stale app build must still be able to log in.
43
+ *
44
+ * @param {object} body the login request body; reads `source` (or `creation_source`) and `appBundleId`
45
+ * @returns {string|null}
46
+ */
47
+ export function appBundleIdOf(body) {
48
+ const source = String(body?.source || body?.creation_source || '').trim().toUpperCase();
49
+ if (source && !MOBILE_LOGIN_SOURCES.has(source) && !CLIENT_APP_LOGIN_SOURCES.has(source)) return null;
50
+ const value = typeof body?.appBundleId === 'string' ? body.appBundleId.trim() : '';
51
+ return APP_BUNDLE_ID_PATTERN.test(value) ? value : null;
52
+ }