@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.
- package/README.md +151 -5
- package/android/build.gradle +35 -3
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
- package/app.plugin.js +89 -8
- package/ios/HybridMyazaFaceDetector.swift +16 -3
- 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 +14 -2
- package/src/MyazaBiometricAuth.tsx +119 -0
- package/src/MyazaKYC.tsx +12 -0
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DashedBorder.tsx +55 -0
- package/src/components/DialCodePicker.tsx +53 -67
- package/src/components/DialCodeRow.tsx +102 -0
- package/src/components/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +2 -176
- package/src/components/KycFlow.tsx +60 -32
- package/src/components/LineSkeleton.tsx +100 -0
- package/src/components/MapChrome.tsx +67 -0
- package/src/components/MapPinMarker.tsx +54 -0
- package/src/components/MapPinPicker.tsx +179 -0
- package/src/components/MilestoneTrack.tsx +155 -0
- package/src/components/MultiIdProgress.tsx +107 -0
- package/src/components/MyazaInput.tsx +39 -4
- package/src/components/MyazaSelect.tsx +2 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/OwnershipSlider.tsx +102 -0
- package/src/components/PhoneNumberInput.tsx +45 -4
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/StaggerIn.tsx +63 -0
- package/src/components/StepView.tsx +43 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/glass/FloatingSheet.tsx +73 -21
- package/src/components/glass/GlassSheet.tsx +14 -2
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +45 -5
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +26 -7
- package/src/config/businessDetailsValidity.ts +52 -0
- package/src/config/businessPrefill.ts +72 -0
- package/src/config/businessSteps.ts +20 -3
- package/src/config/consentStep.ts +19 -0
- 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/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/registrationHint.ts +1 -1
- package/src/config/stepOrder.ts +100 -6
- package/src/config/theme.ts +8 -2
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/website.ts +36 -0
- package/src/config/workflowMerge.ts +16 -5
- 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/index.ts +54 -1
- package/src/lib/address-current-location.ts +171 -0
- package/src/lib/address-field-modes.ts +119 -0
- package/src/lib/address-flow.ts +172 -0
- package/src/lib/address-helpers.ts +48 -0
- package/src/lib/address-line.ts +76 -0
- package/src/lib/address-pin-move.ts +97 -0
- package/src/lib/address-step-recovery.ts +63 -0
- package/src/lib/authed-image.ts +78 -0
- package/src/lib/biometric-auth.ts +52 -0
- package/src/lib/biometric-copy.ts +51 -0
- package/src/lib/captureRing.ts +83 -0
- package/src/lib/contact-recovery.ts +44 -0
- package/src/lib/country-adoption.ts +89 -0
- package/src/lib/inferred-country.ts +65 -0
- package/src/lib/livenessLayout.ts +49 -0
- package/src/lib/map-frame.ts +159 -0
- package/src/lib/map-tiles.ts +143 -0
- package/src/lib/multi-id.ts +210 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/resubmit.ts +130 -0
- package/src/lib/result-copy.ts +116 -0
- package/src/lib/result-wait.ts +53 -0
- package/src/lib/review-map-surface.ts +26 -0
- package/src/lib/scope.ts +31 -0
- package/src/lib/screen-corners.ts +50 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/step-log.ts +24 -3
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/use-multi-id-plan.ts +29 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/liveness/useModelReady.ts +65 -0
- package/src/liveness/visionCameraFaceDetector.ts +35 -0
- package/src/presence/background-math.ts +100 -0
- package/src/presence/background-store.ts +82 -0
- package/src/presence/background.ts +165 -0
- package/src/presence/foreground-service.ts +193 -0
- package/src/presence/fs.ts +51 -0
- package/src/presence/geofence.ts +41 -0
- package/src/presence/math.ts +44 -0
- package/src/presence/post.ts +40 -0
- package/src/presence/report.ts +87 -0
- package/src/presence/sampler.ts +110 -0
- package/src/presence/status.ts +92 -0
- package/src/presence/store.ts +94 -0
- package/src/presence/tier.ts +36 -0
- package/src/presence/watch-wait.ts +81 -0
- package/src/screens/AddressCountryControl.tsx +115 -0
- package/src/screens/ApplicantRoleStep.tsx +9 -5
- package/src/screens/BusinessCheckPanel.tsx +89 -0
- package/src/screens/BusinessDetailsFields.tsx +112 -0
- package/src/screens/BusinessDetailsStep.tsx +133 -145
- package/src/screens/BusinessDocumentSlot.tsx +5 -5
- package/src/screens/BusinessDocumentsStep.tsx +5 -56
- 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 +91 -51
- package/src/screens/ConsentStep.tsx +37 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +5 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +43 -42
- package/src/screens/CountrySelectStep.tsx +13 -147
- 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/LivenessAvatar.tsx +15 -3
- package/src/screens/LivenessStep.tsx +172 -52
- package/src/screens/NfcStep.tsx +14 -0
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -96
- package/src/screens/SubmittedBadge.tsx +25 -0
- package/src/screens/SubmittedError.tsx +64 -0
- package/src/screens/SubmittedResult.tsx +117 -0
- package/src/screens/SubmittedStep.tsx +82 -119
- package/src/screens/SubmittedSuccess.tsx +127 -0
- package/src/screens/SubmittedWaiting.tsx +45 -0
- package/src/screens/address/AddressEntranceStep.tsx +161 -0
- package/src/screens/address/AddressIntroGate.tsx +148 -0
- package/src/screens/address/AddressMapStub.tsx +50 -0
- package/src/screens/address/AddressPinStep.tsx +178 -0
- package/src/screens/address/AddressReviewStep.tsx +162 -0
- package/src/screens/address/AddressSandboxTabs.tsx +158 -0
- package/src/screens/address/AddressSearchField.tsx +115 -0
- package/src/screens/address/AddressSearchStep.tsx +94 -0
- package/src/screens/address/CurrentLocationRow.tsx +135 -0
- package/src/screens/address/DetailsSheet.tsx +175 -0
- package/src/screens/address/DetailsSheetFields.tsx +184 -0
- package/src/screens/address/EntranceDropzone.tsx +178 -0
- package/src/screens/address/EntranceFraming.tsx +66 -0
- package/src/screens/address/EntrancePills.tsx +60 -0
- package/src/screens/address/FramedStreetView.tsx +155 -0
- package/src/screens/address/IntroDisclosures.tsx +210 -0
- package/src/screens/address/LabelDecisionRow.tsx +113 -0
- package/src/screens/address/PinSummaryRow.tsx +105 -0
- package/src/screens/address/ReviewAddressBand.tsx +111 -0
- package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
- package/src/screens/address/ReviewMapPicture.tsx +106 -0
- package/src/screens/address/SearchResults.tsx +105 -0
- package/src/screens/address/SearchScreen.tsx +176 -0
- package/src/screens/address/SkipForNow.tsx +43 -0
- package/src/screens/address/StreetViewChrome.tsx +81 -0
- package/src/screens/address/detail-values.ts +22 -0
- package/src/screens/address/fix-source.ts +27 -0
- package/src/screens/address/index.ts +8 -0
- package/src/screens/address/meta.ts +44 -0
- package/src/screens/address/use-address-flow.ts +200 -0
- package/src/screens/address/use-label-pin.ts +80 -0
- package/src/screens/address/use-pin-actions.ts +192 -0
- package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
- package/src/screens/consent/model.ts +79 -10
- package/src/screens/liveness/CaptureRing.tsx +91 -0
- package/src/screens/liveness/LivenessCamera.tsx +63 -0
- package/src/screens/liveness/LivenessHandover.tsx +43 -0
- package/src/screens/liveness/LivenessOutcome.tsx +4 -3
- package/src/screens/liveness/SelfiePreview.tsx +13 -4
- package/src/screens/liveness/ShutterFlash.tsx +25 -0
- package/src/screens/liveness/index.ts +4 -0
- package/src/screens/liveness/useSelfieUpload.ts +23 -3
- package/src/screens/nfc/NfcReadProgress.tsx +1 -0
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useAwaitingPeople.ts +90 -0
- package/src/screens/useBusinessDocumentAttach.ts +93 -0
- package/src/screens/usePoaAttach.ts +12 -15
- package/src/services/api-biometric.ts +37 -0
- package/src/services/api-types-biometric.ts +41 -0
- package/src/services/api-types.ts +251 -3
- package/src/services/api-verify-types.ts +90 -1
- package/src/services/api.ts +237 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/errors.ts +10 -2
- package/src/services/location.ts +234 -0
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +8 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
- package/src/store/address.ts +97 -0
- package/src/store/businessCheck.ts +113 -0
- package/src/store/derive.ts +44 -4
- package/src/store/kycStore.ts +352 -16
- package/src/store/serverConfig.ts +20 -0
- package/src/store/session.ts +227 -0
- package/src/store/state.ts +289 -2
- package/src/store/submit.ts +80 -13
- package/src/store/submitApplicant.ts +5 -11
- package/src/types/business.ts +23 -2
- package/src/types/config.ts +70 -2
- package/src/types/verification.ts +13 -1
- package/src/types/workflow.ts +112 -1
- 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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
}
|
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
|
+
}
|
|
@@ -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
|
+
|