@myazahq/kyc-sdk-react-native 2.4.0 → 2.6.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 (246) hide show
  1. package/README.md +151 -5
  2. package/android/build.gradle +35 -3
  3. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
  4. package/app.plugin.js +89 -8
  5. package/ios/HybridMyazaFaceDetector.swift +16 -3
  6. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  7. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  8. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  9. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  10. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  11. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  12. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  13. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  14. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  15. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  16. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  17. package/package.json +14 -2
  18. package/src/MyazaBiometricAuth.tsx +119 -0
  19. package/src/MyazaKYC.tsx +12 -0
  20. package/src/components/CountryField.tsx +8 -0
  21. package/src/components/CountryRegionPicker.tsx +184 -0
  22. package/src/components/DashedBorder.tsx +55 -0
  23. package/src/components/DialCodePicker.tsx +53 -67
  24. package/src/components/DialCodeRow.tsx +102 -0
  25. package/src/components/FramedMapPicker.tsx +122 -0
  26. package/src/components/GeoBadge.tsx +34 -0
  27. package/src/components/Icon.tsx +2 -176
  28. package/src/components/KycFlow.tsx +60 -32
  29. package/src/components/LineSkeleton.tsx +100 -0
  30. package/src/components/MapChrome.tsx +67 -0
  31. package/src/components/MapPinMarker.tsx +54 -0
  32. package/src/components/MapPinPicker.tsx +179 -0
  33. package/src/components/MilestoneTrack.tsx +155 -0
  34. package/src/components/MultiIdProgress.tsx +107 -0
  35. package/src/components/MyazaInput.tsx +39 -4
  36. package/src/components/MyazaSelect.tsx +2 -1
  37. package/src/components/OptionRow.tsx +22 -3
  38. package/src/components/OwnershipSlider.tsx +102 -0
  39. package/src/components/PhoneNumberInput.tsx +45 -4
  40. package/src/components/PoweredBy.tsx +5 -5
  41. package/src/components/PresenceBlocks.tsx +153 -0
  42. package/src/components/StaggerIn.tsx +63 -0
  43. package/src/components/StepView.tsx +43 -0
  44. package/src/components/StickyActions.tsx +56 -0
  45. package/src/components/dialCodeRows.ts +64 -0
  46. package/src/components/glass/FloatingSheet.tsx +73 -21
  47. package/src/components/glass/GlassSheet.tsx +14 -2
  48. package/src/components/icon-map.ts +176 -0
  49. package/src/components/stepHeaderMeta.tsx +45 -5
  50. package/src/config/addressCollection.ts +125 -0
  51. package/src/config/biometricOptions.ts +100 -0
  52. package/src/config/business.ts +26 -7
  53. package/src/config/businessDetailsValidity.ts +52 -0
  54. package/src/config/businessPrefill.ts +72 -0
  55. package/src/config/businessSteps.ts +20 -3
  56. package/src/config/consentStep.ts +19 -0
  57. package/src/config/keyPeople.ts +111 -7
  58. package/src/config/keyPeopleCorporate.ts +19 -0
  59. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  60. package/src/config/keyPeopleOwners.ts +26 -0
  61. package/src/config/keyPeoplePrefill.ts +138 -0
  62. package/src/config/keyPeopleSectionDefs.ts +65 -0
  63. package/src/config/keyPeopleSections.ts +167 -0
  64. package/src/config/proofOfAddress.ts +54 -11
  65. package/src/config/regions.ts +24 -0
  66. package/src/config/registrationHint.ts +1 -1
  67. package/src/config/stepOrder.ts +100 -6
  68. package/src/config/theme.ts +8 -2
  69. package/src/config/uploadLimits.ts +35 -0
  70. package/src/config/website.ts +36 -0
  71. package/src/config/workflowMerge.ts +16 -5
  72. package/src/emrtd/activeAuth.ts +100 -0
  73. package/src/emrtd/extras.ts +76 -0
  74. package/src/emrtd/files.ts +5 -0
  75. package/src/emrtd/index.ts +1 -0
  76. package/src/emrtd/read.ts +8 -1
  77. package/src/emrtd/session.ts +41 -0
  78. package/src/emrtd/stages.ts +11 -0
  79. package/src/index.ts +54 -1
  80. package/src/lib/address-current-location.ts +171 -0
  81. package/src/lib/address-field-modes.ts +119 -0
  82. package/src/lib/address-flow.ts +172 -0
  83. package/src/lib/address-helpers.ts +48 -0
  84. package/src/lib/address-line.ts +76 -0
  85. package/src/lib/address-pin-move.ts +97 -0
  86. package/src/lib/address-step-recovery.ts +63 -0
  87. package/src/lib/authed-image.ts +78 -0
  88. package/src/lib/biometric-auth.ts +52 -0
  89. package/src/lib/biometric-copy.ts +51 -0
  90. package/src/lib/captureRing.ts +83 -0
  91. package/src/lib/contact-recovery.ts +44 -0
  92. package/src/lib/country-adoption.ts +89 -0
  93. package/src/lib/inferred-country.ts +65 -0
  94. package/src/lib/livenessLayout.ts +49 -0
  95. package/src/lib/map-frame.ts +159 -0
  96. package/src/lib/map-tiles.ts +143 -0
  97. package/src/lib/multi-id.ts +210 -0
  98. package/src/lib/poa-country-gate.ts +27 -0
  99. package/src/lib/resubmit.ts +130 -0
  100. package/src/lib/result-copy.ts +116 -0
  101. package/src/lib/result-wait.ts +53 -0
  102. package/src/lib/review-map-surface.ts +26 -0
  103. package/src/lib/scope.ts +31 -0
  104. package/src/lib/screen-corners.ts +50 -0
  105. package/src/lib/selfie-upload-wait.ts +79 -0
  106. package/src/lib/step-log.ts +24 -3
  107. package/src/lib/street-view-fov.ts +42 -0
  108. package/src/lib/use-multi-id-plan.ts +29 -0
  109. package/src/lib/webview-available.ts +47 -0
  110. package/src/liveness/useLiveness.ts +6 -1
  111. package/src/liveness/useModelReady.ts +65 -0
  112. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  113. package/src/presence/background-math.ts +100 -0
  114. package/src/presence/background-store.ts +82 -0
  115. package/src/presence/background.ts +165 -0
  116. package/src/presence/foreground-service.ts +193 -0
  117. package/src/presence/fs.ts +51 -0
  118. package/src/presence/geofence.ts +41 -0
  119. package/src/presence/math.ts +44 -0
  120. package/src/presence/post.ts +40 -0
  121. package/src/presence/report.ts +87 -0
  122. package/src/presence/sampler.ts +110 -0
  123. package/src/presence/status.ts +92 -0
  124. package/src/presence/store.ts +94 -0
  125. package/src/presence/tier.ts +36 -0
  126. package/src/presence/watch-wait.ts +81 -0
  127. package/src/screens/AddressCountryControl.tsx +115 -0
  128. package/src/screens/ApplicantRoleStep.tsx +9 -5
  129. package/src/screens/BusinessCheckPanel.tsx +89 -0
  130. package/src/screens/BusinessDetailsFields.tsx +112 -0
  131. package/src/screens/BusinessDetailsStep.tsx +133 -145
  132. package/src/screens/BusinessDocumentSlot.tsx +5 -5
  133. package/src/screens/BusinessDocumentsStep.tsx +5 -56
  134. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  135. package/src/screens/BusinessPickedCard.tsx +86 -0
  136. package/src/screens/BusinessPickedSection.tsx +51 -0
  137. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  138. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  139. package/src/screens/BusinessSearch.tsx +199 -0
  140. package/src/screens/BusinessSearchResults.tsx +115 -0
  141. package/src/screens/CompanyInfoFields.tsx +91 -51
  142. package/src/screens/ConsentStep.tsx +37 -17
  143. package/src/screens/ContactActions.tsx +54 -0
  144. package/src/screens/ContactDestinationField.tsx +5 -0
  145. package/src/screens/ContactEntryPanel.tsx +75 -0
  146. package/src/screens/ContactVerificationStep.tsx +43 -42
  147. package/src/screens/CountrySelectStep.tsx +13 -147
  148. package/src/screens/IdTypeStep.tsx +12 -5
  149. package/src/screens/KeepLinksSheet.tsx +88 -0
  150. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  151. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  152. package/src/screens/KeyPeoplePending.tsx +154 -0
  153. package/src/screens/KeyPeopleSection.tsx +162 -0
  154. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  155. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  156. package/src/screens/KeyPersonCard.tsx +36 -4
  157. package/src/screens/KeyPersonForm.tsx +195 -36
  158. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  159. package/src/screens/KeyPersonOwners.tsx +109 -0
  160. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  161. package/src/screens/KeyPersonSheet.tsx +92 -108
  162. package/src/screens/LivenessAvatar.tsx +15 -3
  163. package/src/screens/LivenessStep.tsx +172 -52
  164. package/src/screens/NfcStep.tsx +14 -0
  165. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  166. package/src/screens/ProofOfAddressParts.tsx +151 -0
  167. package/src/screens/ProofOfAddressStep.tsx +58 -96
  168. package/src/screens/SubmittedBadge.tsx +25 -0
  169. package/src/screens/SubmittedError.tsx +64 -0
  170. package/src/screens/SubmittedResult.tsx +117 -0
  171. package/src/screens/SubmittedStep.tsx +82 -119
  172. package/src/screens/SubmittedSuccess.tsx +127 -0
  173. package/src/screens/SubmittedWaiting.tsx +45 -0
  174. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  175. package/src/screens/address/AddressIntroGate.tsx +148 -0
  176. package/src/screens/address/AddressMapStub.tsx +50 -0
  177. package/src/screens/address/AddressPinStep.tsx +178 -0
  178. package/src/screens/address/AddressReviewStep.tsx +162 -0
  179. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  180. package/src/screens/address/AddressSearchField.tsx +115 -0
  181. package/src/screens/address/AddressSearchStep.tsx +94 -0
  182. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  183. package/src/screens/address/DetailsSheet.tsx +175 -0
  184. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  185. package/src/screens/address/EntranceDropzone.tsx +178 -0
  186. package/src/screens/address/EntranceFraming.tsx +66 -0
  187. package/src/screens/address/EntrancePills.tsx +60 -0
  188. package/src/screens/address/FramedStreetView.tsx +155 -0
  189. package/src/screens/address/IntroDisclosures.tsx +210 -0
  190. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  191. package/src/screens/address/PinSummaryRow.tsx +105 -0
  192. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  193. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  194. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  195. package/src/screens/address/SearchResults.tsx +105 -0
  196. package/src/screens/address/SearchScreen.tsx +176 -0
  197. package/src/screens/address/SkipForNow.tsx +43 -0
  198. package/src/screens/address/StreetViewChrome.tsx +81 -0
  199. package/src/screens/address/detail-values.ts +22 -0
  200. package/src/screens/address/fix-source.ts +27 -0
  201. package/src/screens/address/index.ts +8 -0
  202. package/src/screens/address/meta.ts +44 -0
  203. package/src/screens/address/use-address-flow.ts +200 -0
  204. package/src/screens/address/use-label-pin.ts +80 -0
  205. package/src/screens/address/use-pin-actions.ts +192 -0
  206. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  207. package/src/screens/consent/model.ts +79 -10
  208. package/src/screens/liveness/CaptureRing.tsx +91 -0
  209. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  210. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  211. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  212. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  213. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  214. package/src/screens/liveness/index.ts +4 -0
  215. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  216. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  217. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  218. package/src/screens/useAddressPhotoAttach.ts +77 -0
  219. package/src/screens/useAwaitingPeople.ts +90 -0
  220. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  221. package/src/screens/usePoaAttach.ts +12 -15
  222. package/src/services/api-biometric.ts +37 -0
  223. package/src/services/api-types-biometric.ts +41 -0
  224. package/src/services/api-types.ts +251 -3
  225. package/src/services/api-verify-types.ts +90 -1
  226. package/src/services/api.ts +237 -3
  227. package/src/services/deviceMetadata.ts +1 -1
  228. package/src/services/errors.ts +10 -2
  229. package/src/services/location.ts +234 -0
  230. package/src/services/uploadErrors.ts +30 -0
  231. package/src/services/workflowGate.ts +8 -0
  232. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  233. package/src/store/address.ts +97 -0
  234. package/src/store/businessCheck.ts +113 -0
  235. package/src/store/derive.ts +44 -4
  236. package/src/store/kycStore.ts +352 -16
  237. package/src/store/serverConfig.ts +20 -0
  238. package/src/store/session.ts +227 -0
  239. package/src/store/state.ts +289 -2
  240. package/src/store/submit.ts +80 -13
  241. package/src/store/submitApplicant.ts +5 -11
  242. package/src/types/business.ts +23 -2
  243. package/src/types/config.ts +70 -2
  244. package/src/types/verification.ts +13 -1
  245. package/src/types/workflow.ts +112 -1
  246. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -86,10 +86,27 @@ export type BusinessSectionStep =
86
86
  /** The ordered business-application steps this workflow configures. */
87
87
  export function businessSectionSteps(
88
88
  business: WorkflowBusinessConfig | undefined,
89
- ): BusinessSectionStep[] {
90
- const steps: BusinessSectionStep[] = ['business-details'];
91
- if (hasKeyPeopleCollection(business)) steps.push('business-key-people');
89
+ withQuestionnaire = false,
90
+ withAddressCollection = false,
91
+ ): (BusinessSectionStep | 'questionnaire' | 'address-collection')[] {
92
+ const steps: (BusinessSectionStep | 'questionnaire' | 'address-collection')[] = [
93
+ 'business-details',
94
+ ];
95
+ // The premises pin follows the company details it is about — before the
96
+ // paperwork, and long before the application hands over to other people.
97
+ if (withAddressCollection) steps.push('address-collection');
98
+ // Documents BEFORE key people: they are about the company the applicant has
99
+ // just identified, so they follow that thread, and the register's officer
100
+ // list - which the key-people step is a confirmation of - is what should
101
+ // still be in mind when they get to naming people.
92
102
  if (hasBusinessDocumentsStep(business)) steps.push('business-documents');
103
+ // The questionnaire BEFORE key people: its questions are about the COMPANY
104
+ // (volumes, source of funds), so they belong with the company section — and
105
+ // naming the directors leads into their verification, which is where the
106
+ // application hands over to other people and stops being the applicant's
107
+ // own form to finish. Mirrors the web SDK.
108
+ if (withQuestionnaire) steps.push('questionnaire');
109
+ if (hasKeyPeopleCollection(business)) steps.push('business-key-people');
93
110
  if (hasApplicantVerification(business)) steps.push('applicant-role');
94
111
  return steps;
95
112
  }
@@ -0,0 +1,19 @@
1
+ import type { KYCStep } from '../types/config';
2
+
3
+ // ─── The consent screen switch (`consentStep`) ──────────────────────────────
4
+ //
5
+ // A workflow can switch the opening consent (welcome) screen off, for a host
6
+ // app that has already asked: the flow then opens on its first real step.
7
+ // Mirrors the server's `WorkflowConfigSchema.consentStep` and the web /
8
+ // Flutter helpers of the same name; keep the default in lockstep.
9
+
10
+ /** Whether the flow opens on the consent screen. Absent = yes. */
11
+ export function hasConsentStep(config: { consentStep?: boolean | null }): boolean {
12
+ return config.consentStep !== false;
13
+ }
14
+
15
+ /** The step a built order opens on; 'consent' for an empty order, which no
16
+ * flow produces. */
17
+ export function openingStepOf(order: readonly KYCStep[]): KYCStep {
18
+ return order[0] ?? 'consent';
19
+ }
@@ -8,7 +8,12 @@
8
8
  // names is a risk signal the server flags as `undisclosed`.
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
- import type { KeyPersonRole } from '../types/business';
11
+ export { looksCorporate } from './keyPeopleCorporate';
12
+ import { ownersPayload } from './keyPeopleOwners';
13
+ export { emptyKeyPersonOwner } from './keyPeopleOwnerTypes';
14
+ export type { KeyPersonOwnerEntry } from './keyPeopleOwnerTypes';
15
+ import type { KeyPersonOwnerEntry } from './keyPeopleOwnerTypes';
16
+ import type { KeyPersonRole, WorkflowBusinessConfig } from '../types/business';
12
17
  import { isValidContactEmail } from './contact';
13
18
 
14
19
  /** UI cap on applicant-entered rows (the server accepts ≤20). */
@@ -31,22 +36,100 @@ export const KEY_PERSON_ROLES: KeyPersonRole[] = [
31
36
  /** One row as typed. Everything is a string so a half-filled row is editable. */
32
37
  export interface KeyPersonEntry {
33
38
  name: string;
39
+ /** The headline role — derived from `roles` by precedence (mirrors the
40
+ * server's role/roles pair). One-role surfaces read it. */
34
41
  role: KeyPersonRole;
42
+ /** Every hat this person wears: a director AND a 30% owner is one human,
43
+ * filed twice — never two entries. Never empty. */
44
+ roles: KeyPersonRole[];
45
+ /** Their own words for the position ("CFO, Board Member"). Display only. */
46
+ title: string;
35
47
  email: string;
36
48
  /** The person's own ISO-2 country — a foreign director cannot verify with a
37
49
  * local ID, so this drives their invite's country. */
38
50
  country: string;
39
51
  ownershipPct: string;
52
+ /**
53
+ * This party is a company, not a person.
54
+ *
55
+ * A company can never be a beneficial owner, so it is screened as an entity
56
+ * and never asked to verify an identity it does not have. Saying so is what
57
+ * stops the flow sending a document-and-selfie link to a limited company and
58
+ * then waiting for it.
59
+ */
60
+ isCorporate: boolean;
61
+ /** A corporate party's own registration number, as typed. */
62
+ registrationNumber: string;
63
+ /**
64
+ * The people who own a corporate party, as the applicant knows them. The only
65
+ * route to the humans above a parent no register we serve can be asked about.
66
+ */
67
+ owners: KeyPersonOwnerEntry[];
40
68
  }
41
69
 
42
70
  export function emptyKeyPerson(role: KeyPersonRole = 'director'): KeyPersonEntry {
43
- return { name: '', role, email: '', country: '', ownershipPct: '' };
71
+ return {
72
+ name: '',
73
+ role,
74
+ roles: [role],
75
+ title: '',
76
+ email: '',
77
+ country: '',
78
+ ownershipPct: '',
79
+ isCorporate: false,
80
+ registrationNumber: '',
81
+ owners: [],
82
+ };
44
83
  }
45
84
 
46
- /** Whether a row is complete enough to submit. */
47
- export function isKeyPersonRowValid(row: KeyPersonEntry): boolean {
85
+
86
+ const EMPTY_ROLES: ReadonlySet<KeyPersonRole> = new Set();
87
+
88
+ /**
89
+ * The roles whose EMAIL is mandatory: the ones that are actually sent a
90
+ * verification link. Asking a screening-only signatory for an address blocks
91
+ * the form over a field nothing will read. Mirrors the web SDK's
92
+ * keyPeopleRequireEmail — keep the two in lockstep.
93
+ */
94
+ export function keyPeopleRequireEmail(
95
+ business: WorkflowBusinessConfig | undefined,
96
+ ): Set<KeyPersonRole> {
97
+ const kp = business?.keyPeople;
98
+ if (!kp?.enabled || !kp.collect || !kp.requireEmail) return new Set();
99
+ if (kp.requireEmailRoles?.length) return new Set(kp.requireEmailRoles);
100
+ return new Set(
101
+ KEY_PERSON_ROLES.filter(
102
+ (r) => (kp.perRole?.[r] ?? kp.level ?? 'screening_only') === 'full_kyc',
103
+ ),
104
+ );
105
+ }
106
+
107
+ /** Whether THIS row must carry an email. A company has no inbox and is never
108
+ * invited, so a corporate row is always exempt. */
109
+ export function rowNeedsEmail(
110
+ row: KeyPersonEntry,
111
+ emailRequiredFor: ReadonlySet<KeyPersonRole>,
112
+ ): boolean {
113
+ const roles = row.roles && row.roles.length > 0 ? row.roles : [row.role];
114
+ return !row.isCorporate && roles.some((r) => emailRequiredFor.has(r));
115
+ }
116
+
117
+ /**
118
+ * Whether a row is complete enough to submit.
119
+ *
120
+ * `emailRequiredFor` makes the address mandatory rather than merely
121
+ * well-formed. The server enforces it too, but only at SUBMIT — several steps
122
+ * later, as a generic failure, with no way back to the row that is missing
123
+ * one. Blocking here is where the person can still fix it.
124
+ */
125
+ export function isKeyPersonRowValid(
126
+ row: KeyPersonEntry,
127
+ emailRequiredFor: ReadonlySet<KeyPersonRole> = EMPTY_ROLES,
128
+ ): boolean {
48
129
  if (row.name.trim().length < 2) return false;
49
- if (!KEY_PERSON_ROLES.includes(row.role)) return false;
130
+ const roles = row.roles && row.roles.length > 0 ? row.roles : [row.role];
131
+ if (!roles.every((r) => KEY_PERSON_ROLES.includes(r))) return false;
132
+ if (rowNeedsEmail(row, emailRequiredFor) && row.email.trim() === '') return false;
50
133
  if (row.email.trim() !== '' && !isValidContactEmail(row.email.trim())) return false;
51
134
  if (row.ownershipPct.trim() !== '') {
52
135
  const pct = Number(row.ownershipPct);
@@ -69,9 +152,14 @@ export function isKeyPersonRowBlank(row: KeyPersonEntry): boolean {
69
152
  }
70
153
 
71
154
  /** Rows the user has begun but not made valid — what blocks Continue. */
72
- export function invalidKeyPersonRows(rows: KeyPersonEntry[]): number[] {
155
+ export function invalidKeyPersonRows(
156
+ rows: KeyPersonEntry[],
157
+ emailRequiredFor: ReadonlySet<KeyPersonRole> = EMPTY_ROLES,
158
+ ): number[] {
73
159
  return rows
74
- .map((row, i) => (!isKeyPersonRowBlank(row) && !isKeyPersonRowValid(row) ? i : -1))
160
+ .map((row, i) =>
161
+ !isKeyPersonRowBlank(row) && !isKeyPersonRowValid(row, emailRequiredFor) ? i : -1,
162
+ )
75
163
  .filter((i) => i >= 0);
76
164
  }
77
165
 
@@ -87,9 +175,14 @@ export function keyPeoplePayload(
87
175
  ): Array<{
88
176
  name: string;
89
177
  role: KeyPersonRole;
178
+ roles: KeyPersonRole[];
179
+ title?: string;
90
180
  email?: string;
91
181
  country?: string;
92
182
  ownershipPct?: number;
183
+ isCorporate?: boolean;
184
+ registrationNumber?: string;
185
+ owners?: Array<{ name: string; ownershipPct?: number; email?: string; country?: string }>;
93
186
  isApplicant?: boolean;
94
187
  }> {
95
188
  return rows
@@ -99,9 +192,20 @@ export function keyPeoplePayload(
99
192
  .map(({ row, index }) => ({
100
193
  name: row.name.trim(),
101
194
  role: row.role,
195
+ // Every hat they wear - the server merges the set with its own
196
+ // ownership escalation and derives the headline by precedence.
197
+ roles: row.roles && row.roles.length > 0 ? row.roles : [row.role],
198
+ ...(row.title?.trim() ? { title: row.title.trim() } : {}),
102
199
  ...(row.email.trim() !== '' ? { email: row.email.trim() } : {}),
103
200
  ...(row.country.trim() !== '' ? { country: row.country.trim().toUpperCase() } : {}),
104
201
  ...(row.ownershipPct.trim() !== '' ? { ownershipPct: Number(row.ownershipPct) } : {}),
202
+ // A company cannot also be the person filling in the form, so the
203
+ // applicant's own entry is never sent as one.
204
+ ...(row.isCorporate && index !== applicantIndex ? { isCorporate: true } : {}),
205
+ ...(row.isCorporate && row.registrationNumber.trim() !== ''
206
+ ? { registrationNumber: row.registrationNumber.trim() }
207
+ : {}),
208
+ ...(row.isCorporate && index !== applicantIndex ? ownersPayload(row.owners) : {}),
105
209
  ...(index === applicantIndex ? { isApplicant: true } : {}),
106
210
  }));
107
211
  }
@@ -0,0 +1,19 @@
1
+ // Telling a company apart from a person in a register's officer list.
2
+
3
+ /**
4
+ * Corporate designators, matched only at the END of a name.
5
+ *
6
+ * End-anchored on purpose: "Trust", "Grace" and "Precious" are ordinary
7
+ * Nigerian given names, and nobody is called "X Limited".
8
+ */
9
+ const CORPORATE_SUFFIXES = [
10
+ 'limited', 'ltd', 'plc', 'inc', 'incorporated', 'llc', 'llp', 'gmbh', 'nv', 'bv', 'pty',
11
+ 'corporation', 'corp', 'nominees', 'holdings', 'trustees', 'ventures', 'enterprises',
12
+ ];
13
+
14
+ /** Whether a registry name reads as a company. */
15
+ export function looksCorporate(name: string): boolean {
16
+ const parts = name.toLowerCase().replace(/[.,()]/g, ' ').split(/\s+/).filter(Boolean);
17
+ const last = parts[parts.length - 1];
18
+ return last != null && CORPORATE_SUFFIXES.includes(last);
19
+ }
@@ -0,0 +1,12 @@
1
+ /** One declared owner of a corporate key person. */
2
+ export interface KeyPersonOwnerEntry {
3
+ name: string;
4
+ /** Their share OF THE COMPANY above. The server multiplies it down the chain. */
5
+ ownershipPct: string;
6
+ email: string;
7
+ country: string;
8
+ }
9
+
10
+ export function emptyKeyPersonOwner(): KeyPersonOwnerEntry {
11
+ return { name: '', ownershipPct: '', email: '', country: '' };
12
+ }
@@ -0,0 +1,26 @@
1
+ import type { KeyPersonOwnerEntry } from './keyPeopleOwnerTypes';
2
+
3
+ // The people an applicant says own a corporate shareholder. Declared,
4
+ // corroborated by nothing, and recorded as exactly that.
5
+
6
+ /** Declared owners, dropping the half-typed rows. Absent when none are valid. */
7
+ export function ownersPayload(
8
+ owners: KeyPersonOwnerEntry[] | undefined,
9
+ ): { owners?: Array<{ name: string; ownershipPct?: number; email?: string; country?: string }> } {
10
+ const valid = (owners ?? [])
11
+ .filter((o) => o.name.trim().length >= 2)
12
+ .slice(0, 10)
13
+ .map((o) => {
14
+ const pct = Number(o.ownershipPct);
15
+ return {
16
+ name: o.name.trim(),
17
+ ...(o.ownershipPct.trim() !== '' && Number.isFinite(pct) && pct >= 0 && pct <= 100
18
+ ? { ownershipPct: pct }
19
+ : {}),
20
+ ...(o.email.trim() !== '' ? { email: o.email.trim() } : {}),
21
+ ...(o.country.trim() !== '' ? { country: o.country.trim().toUpperCase() } : {}),
22
+ };
23
+ });
24
+ return valid.length > 0 ? { owners: valid } : {};
25
+ }
26
+
@@ -0,0 +1,138 @@
1
+ import type { KeyPersonEntry } from './keyPeople';
2
+ import type { KeyPersonRole } from '../types/business';
3
+ import { looksCorporate } from './keyPeopleCorporate';
4
+ import { primaryRole } from './keyPeopleSections';
5
+ import type { RegistryOfficer } from '../services/api';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Turning the register's officer list into rows the applicant confirms.
9
+ //
10
+ // This is the whole point of checking at selection: the question stops being
11
+ // "who are your directors?", which is a memory test, and becomes "are these
12
+ // right?", which is a confirmation. It also makes a removal meaningful —
13
+ // taking a name out of a list you were shown is a decision, and the server
14
+ // records it as one (keyPeople.removedFromRegistry).
15
+ //
16
+ // Ported from the web SDK's key-people-prefill.ts — keep the two in lockstep.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * People a register names who are not parties to the business: the agent who
21
+ * filed the papers, the witness to a signature, the lawyer who drew them up,
22
+ * the deponent who swore the declaration. Prefilled as directors, an applicant
23
+ * confirming what looked like their own board would hand us the filing agent
24
+ * as an officer.
25
+ */
26
+ /** The closed role vocabulary a served role must belong to. */
27
+ const VALID_ROLES: KeyPersonRole[] = ['director', 'beneficial_owner', 'signatory', 'shareholder'];
28
+
29
+ const NOT_A_PARTY = ['presenter', 'witness', 'lawyer', 'deponent', 'solicitor', 'notary'];
30
+
31
+ /**
32
+ * A registry designation in our role vocabulary.
33
+ *
34
+ * Falls back to `director` rather than dropping a person: an officer we cannot
35
+ * classify still has to appear, because an unlisted one reads as an omission.
36
+ */
37
+ export function roleFromDesignation(designation: string | null): KeyPersonRole {
38
+ const d = (designation ?? '').toLowerCase();
39
+ // Nigeria's beneficial-ownership register, in CAMA 2020's own words.
40
+ if (d.includes('significant control') || d.includes('psc') || d.includes('beneficial')) {
41
+ return 'beneficial_owner';
42
+ }
43
+ if (d.includes('shareholder') || d.includes('owner') || d.includes('member')) return 'shareholder';
44
+ if (d.includes('secretary') || d.includes('signator')) return 'signatory';
45
+ return 'director';
46
+ }
47
+
48
+ /**
49
+ * Two spellings of one name: enough shared words, and no CONFLICT.
50
+ *
51
+ * Counting shared words alone merged siblings — a double-barrelled family
52
+ * surname supplies two shared words by itself. Each side holding a word the
53
+ * other lacks is a different person; one side holding extras is the fuller
54
+ * spelling. Mirrors the server's namesLikelySame.
55
+ */
56
+ function samePerson(a: string, b: string): boolean {
57
+ const words = (s: string) =>
58
+ new Set(
59
+ s
60
+ .toLowerCase()
61
+ .replace(/[^a-z\s]/g, ' ')
62
+ .split(/\s+/)
63
+ .filter((w) => w.length >= 2),
64
+ );
65
+ const wa = words(a);
66
+ const wb = words(b);
67
+ if (wa.size === 0 || wb.size === 0) return false;
68
+ let shared = 0;
69
+ for (const w of wa) if (wb.has(w)) shared += 1;
70
+ if (shared === 1 && Math.min(wa.size, wb.size) === 1) return true;
71
+ if (shared < 2) return false;
72
+ return shared === wa.size || shared === wb.size;
73
+ }
74
+
75
+ /**
76
+ * Build the rows to start the step with.
77
+ *
78
+ * ONE ROW PER PERSON: a register files one designation per entry and the same
79
+ * human several times over, so entries are merged by name, keeping the
80
+ * classification that asks the most of them.
81
+ */
82
+ export function prefillKeyPeople(
83
+ officers: readonly RegistryOfficer[],
84
+ defaultCountry: string,
85
+ ): KeyPersonEntry[] {
86
+ const rows: KeyPersonEntry[] = [];
87
+ for (const o of officers) {
88
+ const name = (o.name ?? '').trim();
89
+ if (!name) continue;
90
+ const d = (o.designation ?? '').toLowerCase();
91
+ if (NOT_A_PARTY.some((h) => d.includes(h))) continue;
92
+
93
+ // A newer server sends the merged role set; an older one sends the raw
94
+ // designation per entry and the merge below reassembles the person.
95
+ const served = (o.roles ?? []).filter((r): r is KeyPersonRole =>
96
+ VALID_ROLES.includes(r as KeyPersonRole),
97
+ );
98
+ const roles = served.length > 0 ? served : [roleFromDesignation(o.designation)];
99
+ const existing = rows.find((r) => samePerson(r.name, name));
100
+ if (existing) {
101
+ // The register filed the same human under another designation - that is
102
+ // another HAT, not another person. Union the roles; the headline keeps
103
+ // the classification that asks the most of them.
104
+ existing.roles = [...new Set([...existing.roles, ...roles])];
105
+ existing.role = primaryRole(existing.roles);
106
+ if (name.length > existing.name.length) existing.name = name;
107
+ continue;
108
+ }
109
+ rows.push({
110
+ name,
111
+ role: primaryRole(roles),
112
+ roles,
113
+ title: '',
114
+ // Fill what the register actually said - the ownership split it
115
+ // computed from the share counts, an email when one is on file - and
116
+ // invent nothing: a value it did not give stays for the applicant.
117
+ email: (o.email ?? '').trim(),
118
+ country: defaultCountry,
119
+ ownershipPct:
120
+ o.ownershipPct != null && Number.isFinite(o.ownershipPct)
121
+ ? String(o.ownershipPct)
122
+ : '',
123
+ isCorporate: o.isCorporate ?? looksCorporate(name),
124
+ registrationNumber: (o.registrationNumber ?? '').trim(),
125
+ owners: [],
126
+ });
127
+ }
128
+ return rows;
129
+ }
130
+
131
+ /**
132
+ * Whether prefilling would overwrite work. Only ever fills an empty list:
133
+ * someone who already typed a name has told us something the register did
134
+ * not, and replacing it would lose both their input and the signal in it.
135
+ */
136
+ export function shouldPrefill(existing: readonly KeyPersonEntry[]): boolean {
137
+ return existing.every((r) => r.name.trim() === '' && r.email.trim() === '');
138
+ }
@@ -0,0 +1,65 @@
1
+ import type { KeyPeopleSection } from './keyPeopleSections';
2
+ import type { WorkflowBusinessConfig } from '../types/business';
3
+
4
+ // The step's section definitions — which sections show and what they say.
5
+ // Split from keyPeopleSections.ts (200-line rule).
6
+
7
+ /**
8
+ * The register's default beneficial-ownership line, when the workflow does
9
+ * not set its own. Mirrors the server's `uboThresholdFor`: 25 is the
10
+ * FATF/EU/FinCEN indicative figure; Nigeria's CAMA files significant control
11
+ * from a lower bar, so NG defaults to 10. Keep in lockstep with the web SDK.
12
+ */
13
+ export function defaultUboThreshold(country?: string | null): number {
14
+ return (country ?? '').toUpperCase() === 'NG' ? 10 : 25;
15
+ }
16
+
17
+ export interface KeyPeopleSectionDef {
18
+ key: KeyPeopleSection;
19
+ title: string;
20
+ description: string;
21
+ addLabel: string;
22
+ }
23
+
24
+ /**
25
+ * Which sections the step shows, with their plain-language definitions. The
26
+ * definitions carry the REAL threshold (workflow override or the register's
27
+ * default) — a printed band the server does not enforce would be a lie the
28
+ * applicant plans around. Scope follows the workflow's `keyPeople.roles`.
29
+ */
30
+ export function keyPeopleSectionList(
31
+ business: WorkflowBusinessConfig | undefined,
32
+ threshold: number,
33
+ ): KeyPeopleSectionDef[] {
34
+ const scoped = business?.keyPeople?.roles;
35
+ const inScope = (roles: string[]): boolean =>
36
+ !scoped || scoped.length === 0 || roles.some((r) => scoped.includes(r as never));
37
+
38
+ const t = String(threshold);
39
+ const out: KeyPeopleSectionDef[] = [];
40
+ if (inScope(['beneficial_owner'])) {
41
+ out.push({
42
+ key: 'ubos',
43
+ title: 'Beneficial owners',
44
+ description: `Individuals who own ${t}% or more of the company.`,
45
+ addLabel: 'Add a beneficial owner',
46
+ });
47
+ }
48
+ if (inScope(['shareholder'])) {
49
+ out.push({
50
+ key: 'shareholders',
51
+ title: 'Shareholders',
52
+ description: `People or companies holding under ${t}%.`,
53
+ addLabel: 'Add a shareholder',
54
+ });
55
+ }
56
+ if (inScope(['director', 'signatory'])) {
57
+ out.push({
58
+ key: 'representatives',
59
+ title: 'Directors & representatives',
60
+ description: 'People who act on behalf of the company.',
61
+ addLabel: 'Add a representative',
62
+ });
63
+ }
64
+ return out;
65
+ }
@@ -0,0 +1,167 @@
1
+ import type { KeyPersonEntry } from './keyPeople';
2
+ import type { KeyPersonRole } from '../types/business';
3
+
4
+ /**
5
+ * The sectioned key-people model: UBOs / Shareholders / Directors &
6
+ * representatives as VIEWS over one shared list of people.
7
+ *
8
+ * A section is not a bucket. One human is a director AND a 30% owner, and the
9
+ * register files them that way, so an entry appears in every section whose
10
+ * definition it meets — membership is derived from the roles it holds and the
11
+ * stake it declares, never stored. Quick-add grants an existing person another
12
+ * hat instead of retyping them; classification by ownership happens here
13
+ * exactly as the server escalates it, so the screen never disagrees with what
14
+ * the submission will be read as.
15
+ */
16
+
17
+ export type KeyPeopleSection = 'ubos' | 'shareholders' | 'representatives';
18
+
19
+ /** Strongest first — the headline role on one-role surfaces. Mirrors the
20
+ * server's precedence in `key-people/roles.ts`; keep the two in lockstep. */
21
+ const ROLE_PRECEDENCE: KeyPersonRole[] = [
22
+ 'beneficial_owner',
23
+ 'director',
24
+ 'signatory',
25
+ 'shareholder',
26
+ ];
27
+
28
+ export function primaryRole(roles: KeyPersonRole[]): KeyPersonRole {
29
+ for (const role of ROLE_PRECEDENCE) if (roles.includes(role)) return role;
30
+ return 'shareholder';
31
+ }
32
+
33
+ /** The roles an entry actually holds; falls back to the headline for rows
34
+ * minted before `roles` existed (restored sessions). */
35
+ export function rolesOf(entry: Pick<KeyPersonEntry, 'role' | 'roles'>): KeyPersonRole[] {
36
+ return entry.roles && entry.roles.length > 0 ? entry.roles : [entry.role];
37
+ }
38
+
39
+ /** The declared stake as a number, or null when blank/unparseable. */
40
+ export function stakeOf(entry: Pick<KeyPersonEntry, 'ownershipPct'>): number | null {
41
+ const raw = entry.ownershipPct.trim();
42
+ if (raw === '') return null;
43
+ const n = Number(raw);
44
+ return Number.isFinite(n) ? n : null;
45
+ }
46
+
47
+ /**
48
+ * Which sections this entry belongs to.
49
+ *
50
+ * - UBOs: a natural person holding the beneficial-owner role OR a stake at or
51
+ * above the threshold — the same escalation the server performs, so a
52
+ * shareholder who types 60% moves up on screen exactly as they will in the
53
+ * submission. A company never qualifies (a beneficial owner is a natural
54
+ * person in every regime that defines one).
55
+ * - Shareholders: every corporate holder (whatever its stake — the never-a-UBO
56
+ * rule made visible), plus people with a declared holding or shareholder
57
+ * role below the threshold. A person the UBO section claimed is not ALSO a
58
+ * plain shareholder: same stake, one classification.
59
+ * - Representatives: anyone holding director or signatory.
60
+ */
61
+ export function sectionsFor(
62
+ entry: KeyPersonEntry,
63
+ threshold: number,
64
+ ): Set<KeyPeopleSection> {
65
+ const roles = rolesOf(entry);
66
+ const stake = stakeOf(entry);
67
+ const out = new Set<KeyPeopleSection>();
68
+
69
+ const isUbo =
70
+ !entry.isCorporate &&
71
+ (roles.includes('beneficial_owner') || (stake != null && stake >= threshold));
72
+ if (isUbo) out.add('ubos');
73
+ if (
74
+ entry.isCorporate ||
75
+ (!isUbo && (roles.includes('shareholder') || (stake != null && stake > 0)))
76
+ ) {
77
+ out.add('shareholders');
78
+ }
79
+ if (roles.includes('director') || roles.includes('signatory')) {
80
+ out.add('representatives');
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** Indices of the entries each section shows, in list order. */
86
+ export function sectionMembers(
87
+ rows: KeyPersonEntry[],
88
+ threshold: number,
89
+ ): Record<KeyPeopleSection, number[]> {
90
+ const out: Record<KeyPeopleSection, number[]> = {
91
+ ubos: [],
92
+ shareholders: [],
93
+ representatives: [],
94
+ };
95
+ rows.forEach((row, index) => {
96
+ for (const section of sectionsFor(row, threshold)) out[section].push(index);
97
+ });
98
+ return out;
99
+ }
100
+
101
+ /** The role a section's add-tile (and quick-add chip) grants. */
102
+ export const SECTION_ROLE: Record<KeyPeopleSection, KeyPersonRole> = {
103
+ ubos: 'beneficial_owner',
104
+ shareholders: 'shareholder',
105
+ representatives: 'director',
106
+ };
107
+
108
+ /**
109
+ * Entries offerable as quick-add chips for a section: already entered, named,
110
+ * not yet a member, and eligible (a company can never be quick-added as a
111
+ * UBO). One tap grants the section's role — the Didit chip, minus its
112
+ * duplicate-name bug.
113
+ */
114
+ export function quickAddCandidates(
115
+ rows: KeyPersonEntry[],
116
+ section: KeyPeopleSection,
117
+ threshold: number,
118
+ ): number[] {
119
+ const members = new Set(sectionMembers(rows, threshold)[section]);
120
+ return rows
121
+ .map((row, index) => ({ row, index }))
122
+ .filter(({ row, index }) => {
123
+ if (members.has(index)) return false;
124
+ if (row.name.trim().length < 2) return false;
125
+ if (section === 'ubos' && row.isCorporate) return false;
126
+ // Only offer a chip that would DO something. Membership is derived, so
127
+ // granting a role does not always produce it: a beneficial owner is not
128
+ // also a plain shareholder (same stake, one classification), so offering
129
+ // them under Shareholders gave a chip that could be tapped forever and
130
+ // never move anybody. An affordance that does nothing is worse than an
131
+ // absent one, because the applicant concludes the app is broken.
132
+ return sectionsFor(grantRole(row, section), threshold).has(section);
133
+ })
134
+ .map(({ index }) => index);
135
+ }
136
+
137
+ /** Grant an entry another hat (quick-add). The headline follows precedence. */
138
+ export function grantRole(entry: KeyPersonEntry, section: KeyPeopleSection): KeyPersonEntry {
139
+ const roles = rolesOf(entry);
140
+ const granted = SECTION_ROLE[section];
141
+ const next = roles.includes(granted) ? roles : [...roles, granted];
142
+ return { ...entry, roles: next, role: primaryRole(next) };
143
+ }
144
+
145
+ /**
146
+ * Take an entry out of a section (the card's X). Removing the section's roles
147
+ * is enough when membership came from them; when it came from a declared
148
+ * stake (or nothing else keeps the row alive) the honest reading of "remove
149
+ * from UBOs" is "remove this person" — the caller deletes the row when this
150
+ * returns null.
151
+ */
152
+ export function withoutSection(
153
+ entry: KeyPersonEntry,
154
+ section: KeyPeopleSection,
155
+ threshold: number,
156
+ ): KeyPersonEntry | null {
157
+ const dropped: KeyPersonRole[] =
158
+ section === 'representatives' ? ['director', 'signatory'] : [SECTION_ROLE[section]];
159
+ const remaining = rolesOf(entry).filter((r) => !dropped.includes(r));
160
+ if (remaining.length === 0) return null;
161
+ const next = { ...entry, roles: remaining, role: primaryRole(remaining) };
162
+ // Still a member by stake? Then role removal did not remove them, and the
163
+ // tap meant more than that.
164
+ if (sectionsFor(next, threshold).has(section)) return null;
165
+ return next;
166
+ }
167
+