@myazahq/kyc-sdk-react-native 2.0.1 → 2.1.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 +111 -8
- package/android/build.gradle +6 -0
- package/android/src/main/AndroidManifest.xml +14 -4
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaEmrtd.kt +358 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +88 -1
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaRectDetector.kt +34 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +118 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/Jp2Decoder.kt +117 -0
- package/app.plugin.js +102 -13
- package/ios/HybridMyazaEmrtd.swift +447 -0
- package/ios/HybridMyazaFaceDetector.swift +104 -2
- package/ios/HybridMyazaRectDetector.swift +103 -0
- package/ios/HybridMyazaTextRecognizer.swift +111 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/KycSdkReactNative+autolinking.cmake +87 -0
- package/nitrogen/generated/android/KycSdkReactNative+autolinking.gradle +27 -0
- package/nitrogen/generated/android/KycSdkReactNativeOnLoad.cpp +102 -0
- package/nitrogen/generated/android/KycSdkReactNativeOnLoad.hpp +34 -0
- package/nitrogen/generated/android/c++/JDetectedRect.hpp +81 -0
- package/nitrogen/generated/android/c++/JEmrtdApduResponse.hpp +61 -0
- package/nitrogen/generated/android/c++/JEmrtdTagInfo.hpp +61 -0
- package/nitrogen/generated/android/c++/JFaceResult.hpp +113 -0
- package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.cpp +176 -0
- package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.hpp +75 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +60 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.cpp +60 -0
- package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +80 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +64 -0
- package/nitrogen/generated/android/c++/JTextResult.hpp +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/DetectedRect.kt +81 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdApduResponse.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdTagInfo.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/FaceResult.kt +121 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaEmrtdSpec.kt +103 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +55 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaRectDetectorSpec.kt +55 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +60 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/KycSdkReactNativeOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/TextResult.kt +51 -0
- package/nitrogen/generated/ios/KycSdkReactNative+autolinking.rb +62 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +143 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +423 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Umbrella.hpp +81 -0
- package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.mm +57 -0
- package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.swift +62 -0
- package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.hpp +184 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +98 -0
- package/nitrogen/generated/ios/swift/DetectedRect.swift +59 -0
- package/nitrogen/generated/ios/swift/EmrtdApduResponse.swift +34 -0
- package/nitrogen/generated/ios/swift/EmrtdTagInfo.swift +34 -0
- package/nitrogen/generated/ios/swift/FaceResult.swift +99 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_EmrtdApduResponse.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_EmrtdTagInfo.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_TextResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec.swift +67 -0
- package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec_cxx.swift +317 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +143 -0
- package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec_cxx.swift +143 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +57 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +162 -0
- package/nitrogen/generated/ios/swift/TextResult.swift +35 -0
- package/nitrogen/generated/shared/c++/DetectedRect.hpp +107 -0
- package/nitrogen/generated/shared/c++/EmrtdApduResponse.hpp +87 -0
- package/nitrogen/generated/shared/c++/EmrtdTagInfo.hpp +87 -0
- package/nitrogen/generated/shared/c++/FaceResult.hpp +139 -0
- package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.cpp +33 -0
- package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.hpp +80 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +22 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +70 -0
- package/nitrogen/generated/shared/c++/TextResult.hpp +84 -0
- package/package.json +16 -4
- package/src/MyazaKYC.tsx +115 -22
- package/src/capture/documentIdentity.ts +153 -0
- package/src/capture/documentSignals.ts +177 -0
- package/src/capture/documentTextGate.ts +171 -0
- package/src/capture/hints.ts +57 -0
- package/src/capture/index.ts +24 -0
- package/src/capture/rectDetector.ts +84 -0
- package/src/capture/rectGate.ts +91 -0
- package/src/capture/useAutoCapture.ts +198 -0
- package/src/components/CameraPermissionView.tsx +39 -3
- package/src/components/CameraViewfinder.tsx +226 -70
- package/src/components/ContactCodeEntry.tsx +139 -0
- package/src/components/CountryField.tsx +87 -0
- package/src/components/CountryFlag.tsx +15 -15
- package/src/components/DialCodePicker.tsx +186 -0
- package/src/components/DocumentCropper.tsx +1 -1
- package/src/components/DocumentReview.tsx +142 -0
- package/src/components/DocumentReviewSide.tsx +135 -0
- package/src/components/DocumentReviewZoom.tsx +119 -0
- package/src/components/ExpiryCountdown.tsx +52 -0
- package/src/components/FlashOverlay.tsx +156 -0
- package/src/components/Icon.tsx +67 -1
- package/src/components/KycFlow.tsx +66 -94
- package/src/components/KycSheet.tsx +148 -18
- package/src/components/MediaSourceSheet.tsx +163 -0
- package/src/components/MyazaButton.tsx +14 -2
- package/src/components/MyazaDateField.tsx +240 -0
- package/src/components/MyazaInput.tsx +73 -26
- package/src/components/MyazaSelect.tsx +283 -0
- package/src/components/MyazaWordmark.tsx +33 -0
- package/src/components/OptionRow.tsx +132 -0
- package/src/components/PhoneNumberInput.tsx +149 -0
- package/src/components/PoweredBy.tsx +89 -0
- package/src/components/ReadyPrimer.tsx +149 -0
- package/src/components/StepView.tsx +91 -0
- package/src/components/Typography.tsx +18 -3
- package/src/components/VerifiedNotice.tsx +38 -0
- package/src/components/WhatsAppIcon.tsx +29 -0
- package/src/components/WorkflowGate.tsx +128 -0
- package/src/components/brand-font.ts +113 -0
- package/src/components/flashHoleGeometry.ts +35 -0
- package/src/components/flow/FatalConfigError.tsx +56 -0
- package/src/components/fonts.ts +2 -19
- package/src/components/readyPrimerContent.ts +52 -0
- package/src/components/runtime.tsx +38 -43
- package/src/components/stepHeaderMeta.tsx +87 -0
- package/src/components/theme-provider.tsx +89 -0
- package/src/components/useWorkflowMount.ts +116 -0
- package/src/components/viewfinder/CardGuide.tsx +60 -0
- package/src/components/viewfinder/DocumentGhost.tsx +149 -0
- package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
- package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
- package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
- package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
- package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
- package/src/config/brand.ts +97 -0
- package/src/config/business.ts +179 -0
- package/src/config/businessSteps.ts +105 -0
- package/src/config/contact.ts +159 -0
- package/src/config/countryNames.g.ts +254 -0
- package/src/config/currencyFlags.ts +144 -0
- package/src/config/font-resolve.ts +78 -0
- package/src/config/idTypes.ts +98 -1
- package/src/config/keyPeople.ts +152 -0
- package/src/config/phone.ts +39 -0
- package/src/config/proofOfAddress.ts +75 -0
- package/src/config/questionnaire.ts +153 -0
- package/src/config/regions.ts +118 -0
- package/src/config/registrationHint.ts +87 -0
- package/src/config/stepOrder.ts +118 -0
- package/src/config/theme.ts +38 -2
- package/src/config/workflowMerge.ts +124 -0
- package/src/emrtd/bac.ts +128 -0
- package/src/emrtd/bytes.ts +127 -0
- package/src/emrtd/crypto.ts +163 -0
- package/src/emrtd/der.ts +192 -0
- package/src/emrtd/dg2.ts +111 -0
- package/src/emrtd/files.ts +215 -0
- package/src/emrtd/index.ts +30 -0
- package/src/emrtd/mrzKey.ts +54 -0
- package/src/emrtd/native.ts +79 -0
- package/src/emrtd/optionalRead.ts +42 -0
- package/src/emrtd/read.ts +224 -0
- package/src/emrtd/retry.ts +98 -0
- package/src/emrtd/secureMessaging.ts +178 -0
- package/src/emrtd/session.ts +200 -0
- package/src/emrtd/stages.ts +122 -0
- package/src/index.ts +110 -0
- package/src/lib/calendar.ts +71 -0
- package/src/lib/use-keyboard-inset.ts +34 -0
- package/src/liveness/challengeManager.ts +27 -1
- package/src/liveness/faceContinuity.ts +153 -0
- package/src/liveness/flashDetector.ts +145 -0
- package/src/liveness/flashReadyGate.ts +106 -0
- package/src/liveness/flashRunner.ts +118 -0
- package/src/liveness/integritySignals.ts +60 -0
- package/src/liveness/livenessState.ts +139 -0
- package/src/liveness/speech.ts +19 -1
- package/src/liveness/types.ts +48 -1
- package/src/liveness/useFlashPhase.ts +78 -0
- package/src/liveness/useLiveness.ts +260 -75
- package/src/liveness/visionCameraFaceDetector.ts +14 -0
- package/src/mrz/extract.ts +101 -0
- package/src/mrz/parse.ts +178 -0
- package/src/mrz/textRecognizer.ts +149 -0
- package/src/screens/ApplicantRoleStep.tsx +331 -0
- package/src/screens/BusinessDetailsStep.tsx +212 -0
- package/src/screens/BusinessDocumentSlot.tsx +167 -0
- package/src/screens/BusinessDocumentsStep.tsx +213 -0
- package/src/screens/BusinessKeyPeopleStep.tsx +151 -0
- package/src/screens/BusinessKeyPersonRow.tsx +170 -0
- package/src/screens/CompanyInfoFields.tsx +83 -0
- package/src/screens/ConsentStep.tsx +68 -68
- package/src/screens/ContactChannelChoice.tsx +133 -0
- package/src/screens/ContactCodeStep.tsx +130 -0
- package/src/screens/ContactDestinationField.tsx +70 -0
- package/src/screens/ContactFooterNote.tsx +34 -0
- package/src/screens/ContactVerificationStep.tsx +199 -0
- package/src/screens/ContactVerifiedPanel.tsx +36 -0
- package/src/screens/CountrySelectStep.tsx +210 -0
- package/src/screens/DocumentCaptureStep.tsx +190 -145
- package/src/screens/IdInputStep.tsx +19 -9
- package/src/screens/IdTypeStep.tsx +74 -33
- package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
- package/src/screens/LivenessStep.tsx +181 -267
- package/src/screens/MrzScanView.tsx +173 -0
- package/src/screens/NfcStep.tsx +290 -0
- package/src/screens/ProofOfAddressStep.tsx +238 -0
- package/src/screens/QuestionnaireField.tsx +219 -0
- package/src/screens/QuestionnaireMoneyField.tsx +134 -0
- package/src/screens/QuestionnaireStep.tsx +76 -0
- package/src/screens/SubmittedStep.tsx +5 -0
- package/src/screens/consent/model.ts +109 -0
- package/src/screens/document/CameraPhase.tsx +150 -0
- package/src/screens/document/RequiredPill.tsx +60 -0
- package/src/screens/liveness/LightingBanner.tsx +50 -0
- package/src/screens/liveness/LivenessOutcome.tsx +109 -0
- package/src/screens/liveness/ProgressDots.tsx +67 -0
- package/src/screens/liveness/SelfiePreview.tsx +39 -0
- package/src/screens/liveness/constants.ts +31 -0
- package/src/screens/liveness/index.ts +8 -0
- package/src/screens/liveness/resolveGuidance.ts +27 -0
- package/src/screens/liveness/useSelfieUpload.ts +107 -0
- package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
- package/src/screens/nfc/NfcReadBody.tsx +63 -0
- package/src/screens/nfc/NfcReadProgress.tsx +96 -0
- package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
- package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
- package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
- package/src/screens/nfc/availability.ts +73 -0
- package/src/screens/nfc/readErrorMessage.ts +93 -0
- package/src/screens/nfc/useChipPortrait.ts +58 -0
- package/src/screens/nfc/useNfcAvailability.ts +82 -0
- package/src/screens/useFlashHole.ts +105 -0
- package/src/screens/useFlashSequence.ts +96 -0
- package/src/screens/usePoaAttach.ts +121 -0
- package/src/services/api-types.ts +164 -0
- package/src/services/api-verify-types.ts +99 -0
- package/src/services/api.ts +72 -102
- package/src/services/cardCrop.ts +25 -10
- package/src/services/contactErrors.ts +43 -0
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/documentPicker.ts +20 -0
- package/src/services/fingerprint-sources.ts +129 -0
- package/src/services/fingerprint.ts +101 -0
- package/src/services/haptics.ts +38 -0
- package/src/services/mediaCompress.ts +7 -2
- package/src/services/resolveUrl.ts +3 -2
- package/src/services/validators.ts +3 -0
- package/src/services/workflowGate.ts +138 -0
- package/src/specs/MyazaEmrtd.nitro.ts +114 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
- package/src/specs/MyazaRectDetector.nitro.ts +50 -0
- package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
- package/src/store/derive.ts +225 -0
- package/src/store/kycStore.ts +250 -149
- package/src/store/state.ts +253 -0
- package/src/store/submit.ts +101 -0
- package/src/store/submitApplicant.ts +81 -0
- package/src/types/appearance.ts +113 -0
- package/src/types/business.ts +131 -0
- package/src/types/config.ts +150 -119
- package/src/types/id-types.ts +57 -0
- package/src/types/verification.ts +5 -0
- package/src/types/workflow.ts +141 -0
- package/src/utils/amountFormat.ts +73 -0
- package/src/utils/tokens.ts +4 -2
- package/src/__tests__/cardCrop.test.ts +0 -39
- package/src/__tests__/deviceMetadata.test.ts +0 -34
- package/src/__tests__/errors.test.ts +0 -34
- package/src/__tests__/flow.test.ts +0 -61
- package/src/__tests__/gestureDetector.test.ts +0 -37
- package/src/__tests__/liveness.test.ts +0 -112
- package/src/__tests__/resolveUrl.test.ts +0 -64
- package/src/__tests__/validators.test.ts +0 -38
package/src/MyazaKYC.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { Modal, Platform } from 'react-native';
|
|
3
3
|
|
|
4
4
|
// iOS presents the flow as a swipe-down card sheet (the RN equivalent of
|
|
@@ -12,7 +12,10 @@ const MODAL_PRESENTATION = Platform.OS === 'ios' ? 'pageSheet' : 'fullScreen';
|
|
|
12
12
|
const MODAL_ANIMATION = 'slide';
|
|
13
13
|
|
|
14
14
|
import type { KYCStep, MyazaKYCConfig, SupportedCountry } from './types/config';
|
|
15
|
-
import { KycRuntimeProvider,
|
|
15
|
+
import { KycRuntimeProvider, MyazaThemeProvider } from './components/runtime';
|
|
16
|
+
import { WorkflowGate } from './components/WorkflowGate';
|
|
17
|
+
import { useWorkflowMount } from './components/useWorkflowMount';
|
|
18
|
+
import { safeReportError } from './services/errors';
|
|
16
19
|
import { KycFlow, type BackResult } from './components/KycFlow';
|
|
17
20
|
import { MyazaButton } from './components/MyazaButton';
|
|
18
21
|
|
|
@@ -62,10 +65,13 @@ export function MyazaKYC<C extends SupportedCountry = SupportedCountry>(
|
|
|
62
65
|
// The provider wraps BOTH the trigger and the modal so the built-in trigger
|
|
63
66
|
// button can read the theme. The store is created once and `reset()` on each
|
|
64
67
|
// open for a fresh run.
|
|
68
|
+
// The theme provider wraps the trigger so the built-in button is branded
|
|
69
|
+
// before anything is resolved; the flow's own provider (with the resolved
|
|
70
|
+
// workflow's appearance) is mounted inside the modal by the gate.
|
|
65
71
|
return (
|
|
66
|
-
<
|
|
72
|
+
<MyazaThemeProvider appearance={config.appearance}>
|
|
67
73
|
<MyazaKYCTrigger config={config} label={children} disabled={disabled} />
|
|
68
|
-
</
|
|
74
|
+
</MyazaThemeProvider>
|
|
69
75
|
);
|
|
70
76
|
}
|
|
71
77
|
|
|
@@ -78,23 +84,49 @@ function MyazaKYCTrigger({
|
|
|
78
84
|
label?: string;
|
|
79
85
|
disabled?: boolean;
|
|
80
86
|
}): React.ReactElement {
|
|
81
|
-
const
|
|
82
|
-
const [open, setOpen] = useState(false);
|
|
87
|
+
const [wantOpen, setWantOpen] = useState(false);
|
|
83
88
|
// Idempotent close — the X button, Android back, and iOS swipe-down dismiss can
|
|
84
89
|
// each fire; `onClose` must run at most once per open.
|
|
85
90
|
const closedRef = useRef(true);
|
|
86
91
|
const backRef = useRef<(() => BackResult) | null>(null);
|
|
87
92
|
|
|
93
|
+
// Resolution happens HERE, outside the modal, and starts on mount rather than
|
|
94
|
+
// on press. The modal is only presented once it has settled, so its very
|
|
95
|
+
// first frame carries the workflow's own appearance instead of coming up in
|
|
96
|
+
// the default brand and recolouring underneath the user.
|
|
97
|
+
const { state, retry, refresh } = useWorkflowMount(config);
|
|
98
|
+
const settled = state.status !== 'resolving';
|
|
99
|
+
const open = wantOpen && settled;
|
|
100
|
+
|
|
101
|
+
// Reported only once the user has actually tried to start. Prefetching must
|
|
102
|
+
// not fire a consumer's error handler for a flow they never opened.
|
|
103
|
+
const reportedRef = useRef(false);
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!wantOpen || state.status !== 'error' || reportedRef.current) return;
|
|
106
|
+
reportedRef.current = true;
|
|
107
|
+
safeReportError(config.onError, state.error);
|
|
108
|
+
}, [wantOpen, state, config]);
|
|
109
|
+
|
|
110
|
+
const retryFlow = useCallback(() => {
|
|
111
|
+
reportedRef.current = false;
|
|
112
|
+
retry();
|
|
113
|
+
}, [retry]);
|
|
114
|
+
|
|
115
|
+
// Each open mounts a fresh gate → provider → store, so there is no state to
|
|
116
|
+
// reset; the previous run's store is discarded with its provider.
|
|
88
117
|
const openFlow = useCallback(() => {
|
|
89
118
|
closedRef.current = false;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
119
|
+
// Re-resolve on every open: the mount-time prefetch is a warm-up, not a
|
|
120
|
+
// cache, so a workflow published since the app started is still picked up.
|
|
121
|
+
reportedRef.current = false;
|
|
122
|
+
refresh();
|
|
123
|
+
setWantOpen(true);
|
|
124
|
+
}, [refresh]);
|
|
93
125
|
|
|
94
126
|
const close = useCallback(() => {
|
|
95
127
|
if (closedRef.current) return;
|
|
96
128
|
closedRef.current = true;
|
|
97
|
-
|
|
129
|
+
setWantOpen(false);
|
|
98
130
|
config.onClose?.();
|
|
99
131
|
}, [config]);
|
|
100
132
|
|
|
@@ -113,6 +145,10 @@ function MyazaKYCTrigger({
|
|
|
113
145
|
<MyazaButton
|
|
114
146
|
label={label ?? defaultTriggerLabel(config)}
|
|
115
147
|
disabled={disabled}
|
|
148
|
+
// Pressed, but the workflow has not arrived yet. Without this the press
|
|
149
|
+
// would look ignored — gating the modal on a resolved config trades a
|
|
150
|
+
// wrong colour for a dead button unless the wait is shown.
|
|
151
|
+
loading={wantOpen && !settled}
|
|
116
152
|
fullWidth={false}
|
|
117
153
|
onPress={openFlow}
|
|
118
154
|
/>
|
|
@@ -125,7 +161,15 @@ function MyazaKYCTrigger({
|
|
|
125
161
|
onRequestClose={onRequestClose}
|
|
126
162
|
onDismiss={blockDismiss ? undefined : close}
|
|
127
163
|
>
|
|
128
|
-
{open ?
|
|
164
|
+
{open ? (
|
|
165
|
+
<WorkflowGate config={config} state={state} onRetry={retryFlow} onClose={close}>
|
|
166
|
+
{(mount) => (
|
|
167
|
+
<KycRuntimeProvider config={mount.config} serverConfig={mount.serverConfig}>
|
|
168
|
+
<KycFlow onClose={close} backRef={backRef} />
|
|
169
|
+
</KycRuntimeProvider>
|
|
170
|
+
)}
|
|
171
|
+
</WorkflowGate>
|
|
172
|
+
) : null}
|
|
129
173
|
</Modal>
|
|
130
174
|
</>
|
|
131
175
|
);
|
|
@@ -142,6 +186,15 @@ export interface UseMyazaKYCReturn {
|
|
|
142
186
|
open: () => void;
|
|
143
187
|
close: () => void;
|
|
144
188
|
isOpen: boolean;
|
|
189
|
+
/**
|
|
190
|
+
* Opening was requested but the workflow has not resolved yet.
|
|
191
|
+
*
|
|
192
|
+
* Show a spinner on your trigger while this is true. The modal is deliberately
|
|
193
|
+
* withheld until the config has settled so it opens in the right brand rather
|
|
194
|
+
* than recolouring underneath the user — which means the press has to be
|
|
195
|
+
* acknowledged somewhere, or it looks ignored.
|
|
196
|
+
*/
|
|
197
|
+
isPreparing: boolean;
|
|
145
198
|
currentStep: KYCStep | null;
|
|
146
199
|
/** Render this once in your component tree (RN has no implicit portal). */
|
|
147
200
|
MyazaKYCModal: React.FC;
|
|
@@ -150,7 +203,7 @@ export interface UseMyazaKYCReturn {
|
|
|
150
203
|
export function useMyazaKYC<C extends SupportedCountry = SupportedCountry>(
|
|
151
204
|
config: MyazaKYCConfig<C>,
|
|
152
205
|
): UseMyazaKYCReturn {
|
|
153
|
-
const [
|
|
206
|
+
const [wantOpen, setWantOpen] = useState(false);
|
|
154
207
|
const [currentStep, setCurrentStep] = useState<KYCStep | null>(null);
|
|
155
208
|
const closedRef = useRef(true);
|
|
156
209
|
|
|
@@ -164,14 +217,23 @@ export function useMyazaKYC<C extends SupportedCountry = SupportedCountry>(
|
|
|
164
217
|
configRef.current = config;
|
|
165
218
|
const backRef = useRef<(() => BackResult) | null>(null);
|
|
166
219
|
|
|
220
|
+
// `refresh` is defined further down (it needs `wrappedConfig`), so it is
|
|
221
|
+
// reached through a ref rather than reordering the component around it.
|
|
222
|
+
const refreshRef = useRef<() => void>(() => undefined);
|
|
223
|
+
// Only reported once the consumer has actually tried to start — prefetching
|
|
224
|
+
// must not fire their error handler for a flow they never opened.
|
|
225
|
+
const reportedRef = useRef(false);
|
|
167
226
|
const open = useCallback(() => {
|
|
168
227
|
closedRef.current = false;
|
|
169
|
-
|
|
228
|
+
// Re-resolve on every open — see the trigger above.
|
|
229
|
+
reportedRef.current = false;
|
|
230
|
+
refreshRef.current();
|
|
231
|
+
setWantOpen(true);
|
|
170
232
|
}, []);
|
|
171
233
|
const close = useCallback(() => {
|
|
172
234
|
if (closedRef.current) return;
|
|
173
235
|
closedRef.current = true;
|
|
174
|
-
|
|
236
|
+
setWantOpen(false);
|
|
175
237
|
setCurrentStep(null);
|
|
176
238
|
configRef.current.onClose?.();
|
|
177
239
|
}, []);
|
|
@@ -195,6 +257,24 @@ export function useMyazaKYC<C extends SupportedCountry = SupportedCountry>(
|
|
|
195
257
|
[],
|
|
196
258
|
);
|
|
197
259
|
|
|
260
|
+
// Resolution runs outside the modal and starts on mount, so the modal's first
|
|
261
|
+
// frame already carries the workflow's appearance.
|
|
262
|
+
const { state, retry, refresh } = useWorkflowMount(wrappedConfig);
|
|
263
|
+
refreshRef.current = refresh;
|
|
264
|
+
const settled = state.status !== 'resolving';
|
|
265
|
+
const presenting = wantOpen && settled;
|
|
266
|
+
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (!wantOpen || state.status !== 'error' || reportedRef.current) return;
|
|
269
|
+
reportedRef.current = true;
|
|
270
|
+
safeReportError(configRef.current.onError, state.error);
|
|
271
|
+
}, [wantOpen, state]);
|
|
272
|
+
|
|
273
|
+
const retryFlow = useCallback(() => {
|
|
274
|
+
reportedRef.current = false;
|
|
275
|
+
retry();
|
|
276
|
+
}, [retry]);
|
|
277
|
+
|
|
198
278
|
// disableClose blocks user-initiated dismissal — only the returned close()
|
|
199
279
|
// can dismiss. Present full-screen (no iOS swipe-down). The Android hardware
|
|
200
280
|
// back goes through onRequestClose, which walks the flow back a step (or
|
|
@@ -212,7 +292,7 @@ export function useMyazaKYC<C extends SupportedCountry = SupportedCountry>(
|
|
|
212
292
|
const MyazaKYCModal = useCallback<React.FC>(
|
|
213
293
|
() => (
|
|
214
294
|
<Modal
|
|
215
|
-
visible={
|
|
295
|
+
visible={presenting}
|
|
216
296
|
animationType={MODAL_ANIMATION}
|
|
217
297
|
presentationStyle={blockDismiss ? 'fullScreen' : MODAL_PRESENTATION}
|
|
218
298
|
statusBarTranslucent
|
|
@@ -220,16 +300,29 @@ export function useMyazaKYC<C extends SupportedCountry = SupportedCountry>(
|
|
|
220
300
|
onRequestClose={onRequestClose}
|
|
221
301
|
onDismiss={blockDismiss ? undefined : close}
|
|
222
302
|
>
|
|
223
|
-
{/* Fresh provider/store per open. */}
|
|
224
|
-
{
|
|
225
|
-
<
|
|
226
|
-
|
|
227
|
-
|
|
303
|
+
{/* Fresh gate/provider/store per open. */}
|
|
304
|
+
{presenting ? (
|
|
305
|
+
<WorkflowGate config={wrappedConfig} state={state} onRetry={retryFlow} onClose={close}>
|
|
306
|
+
{(mount) => (
|
|
307
|
+
<KycRuntimeProvider config={mount.config} serverConfig={mount.serverConfig}>
|
|
308
|
+
<KycFlow onClose={close} backRef={backRef} />
|
|
309
|
+
</KycRuntimeProvider>
|
|
310
|
+
)}
|
|
311
|
+
</WorkflowGate>
|
|
228
312
|
) : null}
|
|
229
313
|
</Modal>
|
|
230
314
|
),
|
|
231
|
-
[wrappedConfig,
|
|
315
|
+
[wrappedConfig, presenting, state, retryFlow, close, blockDismiss, onRequestClose],
|
|
232
316
|
);
|
|
233
317
|
|
|
234
|
-
|
|
318
|
+
// `isOpen` stays the consumer's intent, not the presentation state — a
|
|
319
|
+
// trigger disabled on it must stay disabled through the preparing window.
|
|
320
|
+
return {
|
|
321
|
+
open,
|
|
322
|
+
close,
|
|
323
|
+
isOpen: wantOpen,
|
|
324
|
+
isPreparing: wantOpen && !settled,
|
|
325
|
+
currentStep,
|
|
326
|
+
MyazaKYCModal,
|
|
327
|
+
};
|
|
235
328
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { detectDocumentType, hasDocumentSignals, hasMrzLines } from './documentSignals';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Document identity.
|
|
5
|
+
//
|
|
6
|
+
// "Is the thing in frame the document the user picked?" — answered from
|
|
7
|
+
// recognized text, so a capture clears the same bar on both platforms.
|
|
8
|
+
//
|
|
9
|
+
// Text ALONE cannot answer it either: the word "passport" appears in prose, in
|
|
10
|
+
// code, and on any screen showing KYC documentation — which is exactly how a
|
|
11
|
+
// laptop screen full of text once identified as a passport and fired.
|
|
12
|
+
//
|
|
13
|
+
// So identity is deliberately strict about the one document that can prove
|
|
14
|
+
// itself: an MRZ. Only travel documents carry one, it is always on the page a
|
|
15
|
+
// passport capture is meant to frame, and it validates by check digit — so for
|
|
16
|
+
// passports the machine-readable zone, not the printed word, is the proof.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What to actually tell the user. The gate rejects for several distinct
|
|
21
|
+
* reasons, and collapsing them into one "align your ID" message left people
|
|
22
|
+
* stuck: the commonest failure is holding the document TOO CLOSE, where the
|
|
23
|
+
* instinct is to move nearer still.
|
|
24
|
+
*/
|
|
25
|
+
export type DocumentHint =
|
|
26
|
+
| 'searching'
|
|
27
|
+
| 'moreLight'
|
|
28
|
+
| 'wrongDocument'
|
|
29
|
+
| 'moveCloser'
|
|
30
|
+
/** Overflowing — corners are cut. THIS is the one users cannot guess. */
|
|
31
|
+
| 'moveBack'
|
|
32
|
+
| 'centre'
|
|
33
|
+
/**
|
|
34
|
+
* Passport only: the page is readable but the machine-readable strip is not
|
|
35
|
+
* in frame. Auto-capture waits for it (it is the chip's key and the proof the
|
|
36
|
+
* page is a passport), so a generic "move closer" sends the user the wrong way.
|
|
37
|
+
*/
|
|
38
|
+
| 'showMrz'
|
|
39
|
+
| 'holdStill'
|
|
40
|
+
| 'captured';
|
|
41
|
+
|
|
42
|
+
export type DocumentFraming =
|
|
43
|
+
| 'none'
|
|
44
|
+
| 'wrongShape'
|
|
45
|
+
| 'adjust'
|
|
46
|
+
| 'holding'
|
|
47
|
+
| 'ready';
|
|
48
|
+
|
|
49
|
+
export interface DocumentGuidance {
|
|
50
|
+
framing: DocumentFraming;
|
|
51
|
+
hint: DocumentHint;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DocumentIdentityResult {
|
|
55
|
+
identified: boolean;
|
|
56
|
+
/** Meaningful only when `identified` is false. */
|
|
57
|
+
hint: DocumentHint;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** ID types whose page always carries a machine-readable zone. */
|
|
61
|
+
export function documentCarriesMrz(idType: string): boolean {
|
|
62
|
+
return idType === 'passport';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface VerifyIdentityOptions {
|
|
66
|
+
country: string;
|
|
67
|
+
idType: string;
|
|
68
|
+
/**
|
|
69
|
+
* Which face of the document is being photographed. Identity KEYWORDS live
|
|
70
|
+
* on the FRONT — the branding, the document name, the issuer. The back of a
|
|
71
|
+
* card (a PVC's barcode-and-address side, a licence's magstripe side)
|
|
72
|
+
* carries none of them, so demanding them there means the back can never
|
|
73
|
+
* auto-capture. By the time the back is up, the user has captured and
|
|
74
|
+
* confirmed the front of the right document seconds ago — identity is
|
|
75
|
+
* established; the back only has to be framed, text-bearing and still.
|
|
76
|
+
*/
|
|
77
|
+
side?: 'front' | 'back';
|
|
78
|
+
/** The chip step needs the MRZ as a key, so demand a check-digit-valid read. */
|
|
79
|
+
requireValidMrz?: boolean;
|
|
80
|
+
hasValidMrz?: boolean;
|
|
81
|
+
/** The key is already stored from an earlier frame. */
|
|
82
|
+
mrzAlreadyCaptured?: boolean;
|
|
83
|
+
minConfidence?: number;
|
|
84
|
+
wrongTypeMargin?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Verifies that `lines` identify the document the user picked. */
|
|
88
|
+
export function verifyDocumentIdentity(
|
|
89
|
+
lines: string[],
|
|
90
|
+
{
|
|
91
|
+
country,
|
|
92
|
+
idType,
|
|
93
|
+
side = 'front',
|
|
94
|
+
requireValidMrz = false,
|
|
95
|
+
hasValidMrz = false,
|
|
96
|
+
mrzAlreadyCaptured = false,
|
|
97
|
+
minConfidence = 0.34,
|
|
98
|
+
wrongTypeMargin = 0.34,
|
|
99
|
+
}: VerifyIdentityOptions,
|
|
100
|
+
): DocumentIdentityResult {
|
|
101
|
+
if (lines.length === 0) return { identified: false, hint: 'searching' };
|
|
102
|
+
const isBack = side === 'back';
|
|
103
|
+
|
|
104
|
+
// ── The MRZ rule ────────────────────────────────────────────────────────
|
|
105
|
+
// For an MRZ-bearing document the zone must actually be in frame. This is
|
|
106
|
+
// what rejects a screen, a printout, or a page that merely mentions the word.
|
|
107
|
+
// Front only: the strip lives on the photo page.
|
|
108
|
+
if (!isBack && documentCarriesMrz(idType) && !hasValidMrz && !hasMrzLines(lines)) {
|
|
109
|
+
// Say WHICH problem it is. Text that already reads as the expected document
|
|
110
|
+
// is most likely the real thing with the strip cropped off the bottom edge
|
|
111
|
+
// — the commonest passport framing mistake — and telling that user "wrong
|
|
112
|
+
// document" sends them hunting for a different one.
|
|
113
|
+
const looksRight = detectDocumentType(lines, country).type === idType;
|
|
114
|
+
return { identified: false, hint: looksRight ? 'showMrz' : 'wrongDocument' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Keyword identity ────────────────────────────────────────────────────
|
|
118
|
+
// Only where signals exist: most Global Documents countries have no curated
|
|
119
|
+
// list, and there we must not block on a check we cannot perform.
|
|
120
|
+
if (hasDocumentSignals(country, idType)) {
|
|
121
|
+
const match = detectDocumentType(lines, country);
|
|
122
|
+
const expected = match.type === idType ? match.confidence : 0;
|
|
123
|
+
|
|
124
|
+
// Reading clearly as a DIFFERENT document — the server would reject this
|
|
125
|
+
// after upload as `document_type_mismatch`, so say so now. This arm runs
|
|
126
|
+
// for the back too: its keywords are absent from card backs, so a hit
|
|
127
|
+
// means a different document's FRONT swapped into the frame.
|
|
128
|
+
if (match.type != null && match.type !== idType && match.confidence >= expected + wrongTypeMargin) {
|
|
129
|
+
return { identified: false, hint: 'wrongDocument' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Two independent keyword hits identify the document, whatever the length
|
|
133
|
+
// of its synonym list. Confidence alone is a ratio, so a type with a
|
|
134
|
+
// thorough list is HARDER to recognise than a sparse one. REQUIRED on the
|
|
135
|
+
// front only — the back carries no branding to match.
|
|
136
|
+
const identifiedByCount = match.type === idType && match.matched >= 2;
|
|
137
|
+
if (!isBack && !identifiedByCount && expected < minConfidence) {
|
|
138
|
+
return { identified: false, hint: lines.length < 5 ? 'searching' : 'moveCloser' };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Chip key ────────────────────────────────────────────────────────────
|
|
143
|
+
// Firing before the MRZ validates means the chip step has to scan the same
|
|
144
|
+
// document a second time, which is the whole reason this gate exists.
|
|
145
|
+
// Front only: the strip cannot appear on the back.
|
|
146
|
+
if (!isBack && requireValidMrz && !hasValidMrz && !mrzAlreadyCaptured) {
|
|
147
|
+
// The document is identified; only the strip is missing. "Move closer"
|
|
148
|
+
// would push the user to crop it off entirely.
|
|
149
|
+
return { identified: false, hint: 'showMrz' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { identified: true, hint: 'holdStill' };
|
|
153
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Document identity signals.
|
|
3
|
+
//
|
|
4
|
+
// "Is the thing in frame actually the document the user picked?" — answered
|
|
5
|
+
// from recognized text alone.
|
|
6
|
+
//
|
|
7
|
+
// Geometry cannot answer it: an aspect ratio cannot tell a passport from a
|
|
8
|
+
// driver's licence. Text can, and more specifically — a passport page says
|
|
9
|
+
// PASSPORT and carries an MRZ; a Nigerian licence says FRSC.
|
|
10
|
+
//
|
|
11
|
+
// Deliberately mirrors the SERVER's DOCUMENT_SIGNALS / detectDocumentType
|
|
12
|
+
// (kyc-core src/lib/ocr-parser.ts). The server rejects a mismatched document
|
|
13
|
+
// AFTER upload with `document_type_mismatch`, so matching its vocabulary here
|
|
14
|
+
// means the camera refuses to shoot exactly what the server would later throw
|
|
15
|
+
// away. Keep the lists in sync when either changes — and with the Flutter SDK's
|
|
16
|
+
// document_type_signals.dart, which is the direct counterpart.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/** Keyword signals per country → ID type. Uppercase; matched as substrings. */
|
|
20
|
+
export const DOCUMENT_SIGNALS: Record<string, Record<string, string[]>> = {
|
|
21
|
+
NG: {
|
|
22
|
+
passport: ['PASSPORT', 'TRAVEL DOCUMENT', 'P<NGA'],
|
|
23
|
+
'drivers-license': [
|
|
24
|
+
'DRIVER', 'LICENSE', 'FRSC', 'FEDERAL ROAD SAFETY', 'DRIVING LICENCE',
|
|
25
|
+
],
|
|
26
|
+
pvc: [
|
|
27
|
+
'VOTER', "VOTER'S CARD", 'PVC', 'INEC', 'INDEPENDENT NATIONAL ELECTORAL',
|
|
28
|
+
'PERMANENT VOTER', 'ELECTORAL COMMISSION', 'VIN',
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
GH: {
|
|
32
|
+
passport: ['PASSPORT', 'REPUBLIC OF GHANA', 'P<GHA'],
|
|
33
|
+
'ghana-card': ['GHANA CARD', 'NATIONAL IDENTIFICATION AUTHORITY', 'NIA', 'GHA-'],
|
|
34
|
+
voters: ['VOTER', 'ELECTORAL COMMISSION', 'EC OF GHANA'],
|
|
35
|
+
'drivers-license': ['DRIVER', 'LICENSE', 'DVLA', 'DRIVING AND VEHICLE'],
|
|
36
|
+
ssnit: ['SSNIT', 'SOCIAL SECURITY', 'NATIONAL INSURANCE'],
|
|
37
|
+
},
|
|
38
|
+
KE: {
|
|
39
|
+
passport: ['PASSPORT', 'REPUBLIC OF KENYA', 'P<KEN'],
|
|
40
|
+
'national-id': [
|
|
41
|
+
'REPUBLIC OF KENYA', 'NATIONAL ID', 'JAMHURI YA KENYA', 'IDENTITY CARD',
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
ZA: {
|
|
45
|
+
passport: ['PASSPORT', 'P<ZAF'],
|
|
46
|
+
'national-id': [
|
|
47
|
+
'REPUBLIC OF SOUTH AFRICA', 'IDENTITY', 'ID NUMBER', 'REPUBLIEK VAN SUID-AFRIKA',
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
CI: {
|
|
51
|
+
cni: ['CARTE NATIONALE', 'IDENTITE', 'REPUBLIQUE DE COTE', 'CNI'],
|
|
52
|
+
'residence-card': ['CARTE DE SEJOUR', 'RESIDENCE', 'TITRE DE SEJOUR'],
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Words that identify a passport in ANY country — an MRZ, or the word itself in
|
|
58
|
+
* English or French. Global Documents means most countries have no curated
|
|
59
|
+
* list, and a passport is the one document that identifies itself everywhere.
|
|
60
|
+
*/
|
|
61
|
+
const UNIVERSAL_PASSPORT_WORDS = ['PASSPORT', 'PASSEPORT'];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* True when `lines` contain two or more machine-readable-zone lines. A cheap
|
|
65
|
+
* STRUCTURAL check — no check-digit validation, which `extractMrz` does.
|
|
66
|
+
*/
|
|
67
|
+
export function hasMrzLines(lines: string[]): boolean {
|
|
68
|
+
let count = 0;
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const stripped = line.toUpperCase().replace(/[^A-Z0-9<]/g, '');
|
|
71
|
+
if (stripped.length >= 40 && stripped.includes('<')) count += 1;
|
|
72
|
+
}
|
|
73
|
+
return count >= 2;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface DocumentTypeMatch {
|
|
77
|
+
/** The ID type the text matches best, or null when nothing matched. */
|
|
78
|
+
type: string | null;
|
|
79
|
+
/**
|
|
80
|
+
* 0..1 share of that type's keywords present.
|
|
81
|
+
*
|
|
82
|
+
* A RATIO, so it shrinks as a type's synonym list grows — which is why it
|
|
83
|
+
* cannot be the only bar for "is this the right document". See `matched`.
|
|
84
|
+
*/
|
|
85
|
+
confidence: number;
|
|
86
|
+
/**
|
|
87
|
+
* How many keywords actually hit.
|
|
88
|
+
*
|
|
89
|
+
* The evidence that matters when deciding the document IS the expected one:
|
|
90
|
+
* two independent hits mean the same thing whether the type lists three
|
|
91
|
+
* synonyms or eight, where `confidence` would call the same evidence 0.67 or
|
|
92
|
+
* 0.25. Kept alongside rather than replacing it — the wrong-type comparison
|
|
93
|
+
* still wants a ratio, so two types are weighed on one scale.
|
|
94
|
+
*/
|
|
95
|
+
matched: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const NO_MATCH: DocumentTypeMatch = { type: null, confidence: 0, matched: 0 };
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* True when we have signals to verify this (country, idType) at all. When
|
|
102
|
+
* false the caller CANNOT check identity and must not block on it — most
|
|
103
|
+
* Global Documents countries have no curated list.
|
|
104
|
+
*/
|
|
105
|
+
export function hasDocumentSignals(country: string, idType: string): boolean {
|
|
106
|
+
if (DOCUMENT_SIGNALS[country]?.[idType]) return true;
|
|
107
|
+
// A passport identifies itself in any country.
|
|
108
|
+
return idType === 'passport';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isShortAcronym(keyword: string): boolean {
|
|
112
|
+
return keyword.length <= 4 && /^[A-Z]+$/.test(keyword);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* How many of `keywords` appear in `upper`.
|
|
117
|
+
*
|
|
118
|
+
* Short acronyms match on WORD BOUNDARIES, not as bare substrings. `VIN` sits
|
|
119
|
+
* inside "driVINg", so a Nigerian driver's licence would otherwise score a
|
|
120
|
+
* voter-card hit — harmless while every score is diluted by the keyword count,
|
|
121
|
+
* and a false identification the moment anything counts hits directly.
|
|
122
|
+
*/
|
|
123
|
+
export function countSignalHits(upper: string, keywords: string[]): number {
|
|
124
|
+
let hits = 0;
|
|
125
|
+
for (const keyword of keywords) {
|
|
126
|
+
if (isShortAcronym(keyword)) {
|
|
127
|
+
if (new RegExp(`\\b${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(upper)) {
|
|
128
|
+
hits += 1;
|
|
129
|
+
}
|
|
130
|
+
} else if (upper.includes(keyword)) {
|
|
131
|
+
hits += 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return hits;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Best-matching document type for the recognized `lines` within `country`. */
|
|
138
|
+
export function detectDocumentType(lines: string[], country: string): DocumentTypeMatch {
|
|
139
|
+
if (lines.length === 0) return NO_MATCH;
|
|
140
|
+
const upper = lines.join('\n').toUpperCase();
|
|
141
|
+
const signals = DOCUMENT_SIGNALS[country] ?? {};
|
|
142
|
+
|
|
143
|
+
let bestType: string | null = null;
|
|
144
|
+
let bestConfidence = 0;
|
|
145
|
+
let bestMatched = 0;
|
|
146
|
+
|
|
147
|
+
for (const [type, keywords] of Object.entries(signals)) {
|
|
148
|
+
if (keywords.length === 0) continue;
|
|
149
|
+
const matched = countSignalHits(upper, keywords);
|
|
150
|
+
const confidence = matched / keywords.length;
|
|
151
|
+
if (confidence > bestConfidence) {
|
|
152
|
+
bestConfidence = confidence;
|
|
153
|
+
bestMatched = matched;
|
|
154
|
+
bestType = type;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Universal passport fallback — an MRZ is worth a lot on its own, since only
|
|
159
|
+
// travel documents carry one.
|
|
160
|
+
const mrz = hasMrzLines(lines);
|
|
161
|
+
const saysPassport = UNIVERSAL_PASSPORT_WORDS.some((w) => upper.includes(w));
|
|
162
|
+
if (mrz || saysPassport) {
|
|
163
|
+
const keywords = signals.passport ?? [];
|
|
164
|
+
const matched = countSignalHits(upper, keywords);
|
|
165
|
+
const base = keywords.length === 0 ? 0 : matched / keywords.length;
|
|
166
|
+
const confidence = Math.min(1, Math.max(0, base + (mrz ? 0.5 : 0.25)));
|
|
167
|
+
if (confidence > bestConfidence) {
|
|
168
|
+
bestConfidence = confidence;
|
|
169
|
+
bestMatched = matched + 1; // the MRZ / the word itself is evidence too
|
|
170
|
+
bestType = 'passport';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return bestType == null
|
|
175
|
+
? NO_MATCH
|
|
176
|
+
: { type: bestType, confidence: bestConfidence, matched: bestMatched };
|
|
177
|
+
}
|