@myazahq/kyc-sdk-react-native 2.4.0 → 2.5.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/android/build.gradle +35 -3
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +75 -0
- package/ios/HybridMyazaFaceDetector.swift +8 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
- package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
- package/package.json +1 -1
- package/src/MyazaKYC.tsx +12 -0
- package/src/components/DashedBorder.tsx +55 -0
- package/src/components/DialCodePicker.tsx +14 -7
- package/src/components/Icon.tsx +9 -0
- package/src/components/MultiIdProgress.tsx +107 -0
- package/src/components/MyazaInput.tsx +23 -4
- package/src/components/MyazaSelect.tsx +2 -1
- package/src/components/OwnershipSlider.tsx +102 -0
- package/src/components/PhoneNumberInput.tsx +37 -4
- package/src/components/StaggerIn.tsx +63 -0
- package/src/components/StepView.tsx +26 -0
- package/src/components/glass/FloatingSheet.tsx +73 -21
- package/src/components/glass/GlassSheet.tsx +14 -2
- package/src/components/stepHeaderMeta.tsx +3 -1
- package/src/config/business.ts +6 -6
- package/src/config/businessDetailsValidity.ts +52 -0
- package/src/config/businessPrefill.ts +72 -0
- package/src/config/businessSteps.ts +14 -3
- package/src/config/keyPeople.ts +111 -7
- package/src/config/keyPeopleCorporate.ts +19 -0
- package/src/config/keyPeopleOwnerTypes.ts +12 -0
- package/src/config/keyPeopleOwners.ts +26 -0
- package/src/config/keyPeoplePrefill.ts +138 -0
- package/src/config/keyPeopleSectionDefs.ts +65 -0
- package/src/config/keyPeopleSections.ts +167 -0
- package/src/config/registrationHint.ts +1 -1
- package/src/config/stepOrder.ts +24 -4
- package/src/config/theme.ts +8 -2
- package/src/config/website.ts +36 -0
- package/src/config/workflowMerge.ts +5 -0
- package/src/emrtd/activeAuth.ts +100 -0
- package/src/emrtd/extras.ts +76 -0
- package/src/emrtd/files.ts +5 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/read.ts +8 -1
- package/src/emrtd/session.ts +41 -0
- package/src/emrtd/stages.ts +11 -0
- package/src/lib/contact-recovery.ts +44 -0
- package/src/lib/multi-id.ts +210 -0
- package/src/lib/resubmit.ts +130 -0
- package/src/lib/screen-corners.ts +50 -0
- package/src/lib/step-log.ts +24 -3
- package/src/lib/use-multi-id-plan.ts +29 -0
- package/src/liveness/useModelReady.ts +65 -0
- package/src/liveness/visionCameraFaceDetector.ts +35 -0
- package/src/screens/ApplicantRoleStep.tsx +9 -5
- package/src/screens/BusinessCheckPanel.tsx +89 -0
- package/src/screens/BusinessDetailsFields.tsx +109 -0
- package/src/screens/BusinessDetailsStep.tsx +131 -145
- package/src/screens/BusinessDocumentSlot.tsx +2 -3
- package/src/screens/BusinessDocumentsStep.tsx +1 -1
- package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
- package/src/screens/BusinessPickedCard.tsx +86 -0
- package/src/screens/BusinessPickedSection.tsx +51 -0
- package/src/screens/BusinessRegistryPickers.tsx +67 -0
- package/src/screens/BusinessSandboxToggle.tsx +114 -0
- package/src/screens/BusinessSearch.tsx +199 -0
- package/src/screens/BusinessSearchResults.tsx +115 -0
- package/src/screens/CompanyInfoFields.tsx +86 -51
- package/src/screens/ConsentStep.tsx +31 -0
- package/src/screens/ContactDestinationField.tsx +1 -0
- package/src/screens/ContactVerificationStep.tsx +34 -3
- package/src/screens/IdTypeStep.tsx +12 -5
- package/src/screens/KeepLinksSheet.tsx +88 -0
- package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
- package/src/screens/KeyPeopleAwaitList.tsx +126 -0
- package/src/screens/KeyPeoplePending.tsx +154 -0
- package/src/screens/KeyPeopleSection.tsx +162 -0
- package/src/screens/KeyPeopleSectionsList.tsx +103 -0
- package/src/screens/KeyPeopleUboExemption.tsx +72 -0
- package/src/screens/KeyPersonCard.tsx +36 -4
- package/src/screens/KeyPersonForm.tsx +195 -36
- package/src/screens/KeyPersonKindToggle.tsx +68 -0
- package/src/screens/KeyPersonOwners.tsx +109 -0
- package/src/screens/KeyPersonRoleChips.tsx +68 -0
- package/src/screens/KeyPersonSheet.tsx +92 -108
- package/src/screens/LivenessStep.tsx +18 -0
- package/src/screens/NfcStep.tsx +14 -0
- package/src/screens/ProofOfAddressStep.tsx +6 -3
- package/src/screens/SubmittedStep.tsx +66 -10
- package/src/screens/nfc/NfcReadProgress.tsx +1 -0
- package/src/screens/useAwaitingPeople.ts +90 -0
- package/src/services/api-types.ts +158 -2
- package/src/services/api-verify-types.ts +53 -1
- package/src/services/api.ts +104 -0
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/errors.ts +10 -2
- package/src/services/workflowGate.ts +1 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
- package/src/store/businessCheck.ts +113 -0
- package/src/store/derive.ts +15 -0
- package/src/store/kycStore.ts +241 -14
- package/src/store/serverConfig.ts +2 -0
- package/src/store/session.ts +208 -0
- package/src/store/state.ts +155 -2
- package/src/store/submit.ts +63 -13
- package/src/store/submitApplicant.ts +5 -11
- package/src/types/business.ts +23 -2
- package/src/types/config.ts +22 -1
- package/src/types/verification.ts +1 -1
- package/src/types/workflow.ts +15 -0
- package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
package/src/config/business.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// here needs to be authoritative, only honest about what to type.
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
|
|
10
|
+
import { COMPANY_INFO_FIELDS } from '../types/business';
|
|
10
11
|
import type {
|
|
11
12
|
CompanyInfoField,
|
|
12
13
|
CompanyInfoMode,
|
|
@@ -159,12 +160,11 @@ export function companyInfoFieldModes(
|
|
|
159
160
|
): Record<CompanyInfoField, CompanyInfoMode> {
|
|
160
161
|
const off = business?.collectCompanyInfo === false;
|
|
161
162
|
const modes = business?.companyInfo ?? {};
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
};
|
|
163
|
+
// Built from the field list so adding one on the server means adding it in a
|
|
164
|
+
// single place here, rather than a line that is easy to forget.
|
|
165
|
+
return Object.fromEntries(
|
|
166
|
+
COMPANY_INFO_FIELDS.map((f) => [f, off ? 'off' : (modes[f] ?? 'optional')]),
|
|
167
|
+
) as Record<CompanyInfoField, CompanyInfoMode>;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
/** Company-profile fields still missing that the workflow marks required. */
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { isValidContactEmail } from './contact';
|
|
2
|
+
import { isValidWebsite } from './website';
|
|
3
|
+
import type { CompanyInfoField, CompanyInfoMode } from '../types/business';
|
|
4
|
+
import type { BusinessCheckState, BusinessState } from '../store/state';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Per-phase validity for the business-details step (200-line rule split).
|
|
8
|
+
//
|
|
9
|
+
// Gated per phase: on the pick screen the detail fields do not exist yet, so
|
|
10
|
+
// holding Continue until they are filled would be waiting on inputs that are
|
|
11
|
+
// not on screen. Empty is fine (optional unless the workflow says otherwise);
|
|
12
|
+
// a malformed value is not. Mirrors the web SDK's isFormValid exactly.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export function businessDetailsValid(input: {
|
|
16
|
+
phase: 'pick' | 'details';
|
|
17
|
+
business: BusinessState;
|
|
18
|
+
modes: Record<CompanyInfoField, CompanyInfoMode>;
|
|
19
|
+
product: string | null | undefined;
|
|
20
|
+
numberValid: boolean;
|
|
21
|
+
nameRequired: boolean;
|
|
22
|
+
showContactEmail: boolean;
|
|
23
|
+
}): boolean {
|
|
24
|
+
const { business, modes } = input;
|
|
25
|
+
const pickValid =
|
|
26
|
+
!!input.product &&
|
|
27
|
+
input.numberValid &&
|
|
28
|
+
(!input.nameRequired || business.registrationName.trim() !== '');
|
|
29
|
+
if (input.phase === 'pick') return pickValid;
|
|
30
|
+
|
|
31
|
+
const showCompanyInfo = Object.values(modes).some((m) => m !== 'off');
|
|
32
|
+
const contactEmailValid =
|
|
33
|
+
business.contactEmail.trim() === '' || isValidContactEmail(business.contactEmail.trim());
|
|
34
|
+
const businessEmailValid =
|
|
35
|
+
business.email.trim() === '' || isValidContactEmail(business.email.trim());
|
|
36
|
+
const companyInfoComplete = (Object.keys(modes) as CompanyInfoField[]).every(
|
|
37
|
+
(f) => modes[f] !== 'required' || business[f].trim() !== '',
|
|
38
|
+
);
|
|
39
|
+
return (
|
|
40
|
+
pickValid &&
|
|
41
|
+
(!input.showContactEmail || contactEmailValid) &&
|
|
42
|
+
(!showCompanyInfo ||
|
|
43
|
+
(businessEmailValid && isValidWebsite(business.website) && companyInfoComplete))
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Whether the check panel has anything to say. Found/skipped/idle stay
|
|
48
|
+
* silent, and so does 'checking' — the loader lives INSIDE the Continue
|
|
49
|
+
* button, since that is the thing the person just pressed. */
|
|
50
|
+
export function checkPanelVisible(status: BusinessCheckState['status']): boolean {
|
|
51
|
+
return status === 'not_found' || status === 'limit_reached' || status === 'unavailable';
|
|
52
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { BusinessCompanyRecord } from '../services/api';
|
|
2
|
+
import type { BusinessState } from '../store/state';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Copy what the register returned into the form, without overwriting anything.
|
|
6
|
+
//
|
|
7
|
+
// Only empty fields are filled: an applicant who typed something before the
|
|
8
|
+
// lookup meant it, and the register is a starting point here rather than the
|
|
9
|
+
// last word. Every value stays editable either way. Returns the patch AND the
|
|
10
|
+
// keys it filled, so a company change can clear exactly those and leave the
|
|
11
|
+
// applicant's own answers alone. Mirrors the web SDK's prefillFromRegister.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export function registerPrefillPatch(
|
|
15
|
+
company: BusinessCompanyRecord | null,
|
|
16
|
+
current: BusinessState,
|
|
17
|
+
): { patch: Partial<BusinessState>; prefilled: (keyof BusinessState)[] } {
|
|
18
|
+
const patch: Partial<BusinessState> = {};
|
|
19
|
+
if (!company) return { patch, prefilled: [] };
|
|
20
|
+
|
|
21
|
+
// Field by field, from the register's answer to the form's question. The
|
|
22
|
+
// register does not answer all of these for every company, so each is
|
|
23
|
+
// filled only when it actually came back.
|
|
24
|
+
const fill = (
|
|
25
|
+
key:
|
|
26
|
+
| 'registrationName' | 'address' | 'companyType' | 'email' | 'phone'
|
|
27
|
+
| 'taxId' | 'vatNumber' | 'dateOfIncorporation' | 'natureOfBusiness',
|
|
28
|
+
value: string | null | undefined,
|
|
29
|
+
): void => {
|
|
30
|
+
if (value && !current[key].trim()) patch[key] = value;
|
|
31
|
+
};
|
|
32
|
+
fill('registrationName', company.name);
|
|
33
|
+
// The register splits the address across lines; the form has one box, so
|
|
34
|
+
// they are joined rather than dropping the parts that did not fit.
|
|
35
|
+
fill(
|
|
36
|
+
'address',
|
|
37
|
+
[company.address, company.city, company.state].filter(Boolean).join(', ') || null,
|
|
38
|
+
);
|
|
39
|
+
fill('companyType', company.typeOfEntity);
|
|
40
|
+
fill('email', company.email);
|
|
41
|
+
fill('phone', company.phone);
|
|
42
|
+
fill('taxId', company.taxId);
|
|
43
|
+
fill('vatNumber', company.vatNumber);
|
|
44
|
+
fill('natureOfBusiness', company.natureOfBusiness);
|
|
45
|
+
// The register gives an incorporation DATE; the field wants YYYY-MM-DD, and
|
|
46
|
+
// anything it cannot be read as is left blank rather than guessed at.
|
|
47
|
+
fill('dateOfIncorporation', isoDateOnly(company.registrationDate));
|
|
48
|
+
|
|
49
|
+
return { patch, prefilled: Object.keys(patch) as (keyof BusinessState)[] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The date part of an ISO timestamp, or null.
|
|
54
|
+
*
|
|
55
|
+
* Deliberately NOT `new Date(value)`. That parser is lenient in exactly the
|
|
56
|
+
* wrong direction: it reads "12/03/2018" as 2 December (US order, when a
|
|
57
|
+
* register returning DD/MM means 12 March), "sometime in 2018" as 2017-12-31,
|
|
58
|
+
* and "March 2018" as the 28th. Each of those is a confidently wrong date
|
|
59
|
+
* written into a compliance form, which is worse than an empty field somebody
|
|
60
|
+
* fills in themselves. So only an unambiguous ISO date is accepted, and the
|
|
61
|
+
* calendar is checked afterwards so 2018-02-31 does not roll into March.
|
|
62
|
+
*/
|
|
63
|
+
export function isoDateOnly(value: string | null | undefined): string | null {
|
|
64
|
+
if (!value) return null;
|
|
65
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s]|$)/.exec(value.trim());
|
|
66
|
+
if (!match) return null;
|
|
67
|
+
const [, y, m, d] = match;
|
|
68
|
+
const parsed = new Date(`${y}-${m}-${d}T00:00:00Z`);
|
|
69
|
+
if (Number.isNaN(parsed.getTime())) return null;
|
|
70
|
+
// A real date, not one that rolled over into the next month.
|
|
71
|
+
return parsed.toISOString().slice(0, 10) === `${y}-${m}-${d}` ? `${y}-${m}-${d}` : null;
|
|
72
|
+
}
|
|
@@ -86,10 +86,21 @@ export type BusinessSectionStep =
|
|
|
86
86
|
/** The ordered business-application steps this workflow configures. */
|
|
87
87
|
export function businessSectionSteps(
|
|
88
88
|
business: WorkflowBusinessConfig | undefined,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
withQuestionnaire = false,
|
|
90
|
+
): (BusinessSectionStep | 'questionnaire')[] {
|
|
91
|
+
const steps: (BusinessSectionStep | 'questionnaire')[] = ['business-details'];
|
|
92
|
+
// Documents BEFORE key people: they are about the company the applicant has
|
|
93
|
+
// just identified, so they follow that thread, and the register's officer
|
|
94
|
+
// list - which the key-people step is a confirmation of - is what should
|
|
95
|
+
// still be in mind when they get to naming people.
|
|
92
96
|
if (hasBusinessDocumentsStep(business)) steps.push('business-documents');
|
|
97
|
+
// The questionnaire BEFORE key people: its questions are about the COMPANY
|
|
98
|
+
// (volumes, source of funds), so they belong with the company section — and
|
|
99
|
+
// naming the directors leads into their verification, which is where the
|
|
100
|
+
// application hands over to other people and stops being the applicant's
|
|
101
|
+
// own form to finish. Mirrors the web SDK.
|
|
102
|
+
if (withQuestionnaire) steps.push('questionnaire');
|
|
103
|
+
if (hasKeyPeopleCollection(business)) steps.push('business-key-people');
|
|
93
104
|
if (hasApplicantVerification(business)) steps.push('applicant-role');
|
|
94
105
|
return steps;
|
|
95
106
|
}
|
package/src/config/keyPeople.ts
CHANGED
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
// names is a risk signal the server flags as `undisclosed`.
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
|
-
|
|
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 {
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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(
|
|
155
|
+
export function invalidKeyPersonRows(
|
|
156
|
+
rows: KeyPersonEntry[],
|
|
157
|
+
emailRequiredFor: ReadonlySet<KeyPersonRole> = EMPTY_ROLES,
|
|
158
|
+
): number[] {
|
|
73
159
|
return rows
|
|
74
|
-
.map((row, i) =>
|
|
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
|
+
}
|