@myazahq/kyc-sdk-react-native 2.3.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 -2
- package/src/MyazaKYC.tsx +12 -0
- package/src/components/BrandBar.tsx +137 -0
- package/src/components/DashedBorder.tsx +55 -0
- package/src/components/DialCodePicker.tsx +25 -24
- package/src/components/DocumentReviewSide.tsx +34 -14
- package/src/components/Icon.tsx +14 -0
- package/src/components/KycSheet.tsx +39 -140
- package/src/components/MediaSourceSheet.tsx +7 -37
- package/src/components/MultiIdProgress.tsx +107 -0
- package/src/components/MyazaDateField.tsx +6 -20
- package/src/components/MyazaInput.tsx +23 -4
- package/src/components/MyazaSelect.tsx +17 -40
- package/src/components/OwnershipSlider.tsx +102 -0
- package/src/components/PhoneNumberInput.tsx +37 -4
- package/src/components/SandboxBanner.tsx +92 -0
- package/src/components/StaggerIn.tsx +63 -0
- package/src/components/StepHeader.tsx +15 -2
- package/src/components/StepView.tsx +26 -0
- package/src/components/glass/ChromeGlass.tsx +65 -0
- package/src/components/glass/FloatingSheet.tsx +242 -0
- package/src/components/glass/GlassSheet.tsx +50 -0
- package/src/components/glass/GlassSurface.tsx +31 -3
- package/src/components/stepHeaderMeta.tsx +3 -1
- package/src/components/viewfinder/ImmersiveBottomBar.tsx +28 -17
- package/src/components/viewfinder/ImmersiveControls.tsx +26 -14
- package/src/components/viewfinder/ViewfinderControls.tsx +29 -7
- 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 +29 -3
- package/src/types/verification.ts +1 -1
- package/src/types/workflow.ts +15 -0
- package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
package/src/store/kycStore.ts
CHANGED
|
@@ -22,13 +22,43 @@ import { INITIAL_SERVER_CONFIG, describeConfigError, type ServerConfigState } fr
|
|
|
22
22
|
import {
|
|
23
23
|
EMPTY_BUSINESS,
|
|
24
24
|
EMPTY_BUSINESS_APPLICATION,
|
|
25
|
+
EMPTY_BUSINESS_CHECK,
|
|
25
26
|
type KYCSubmissionResult,
|
|
26
27
|
type KycState,
|
|
27
28
|
type KycStore,
|
|
28
29
|
} from './state';
|
|
29
30
|
import { nextStepAfter, nfcDecision, previousStepBefore } from './derive';
|
|
31
|
+
|
|
32
|
+
/** The steps that make up ONE ID's evidence. Leaving this set is what ends a
|
|
33
|
+
* multi-ID check — the leg has several exits depending on the ID. */
|
|
34
|
+
const ID_EVIDENCE_STEPS = new Set<KYCStep>(['id-input', 'document-capture', 'nfc']);
|
|
35
|
+
|
|
36
|
+
/** The 1-based check the applicant is on, or undefined outside a multi-ID run.
|
|
37
|
+
* Only emitted once a slot has been COMMITTED — on an ordinary run the field
|
|
38
|
+
* would be a constant 1 on every entry, which is noise. */
|
|
39
|
+
function multiIdSlotOf(s: KycState): number | undefined {
|
|
40
|
+
return s.multiIdSlots.length > 0 ? s.multiIdSlots.length + 1 : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The ID selected at this moment — see StepLogEntry.idType. */
|
|
44
|
+
function selectedIdTypeOf(s: KycState): string | undefined {
|
|
45
|
+
return s.selectedIdType ?? undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The active multi-ID plan for the store's current state, or null. */
|
|
49
|
+
function multiIdPlanFor(s: KycState): ReturnType<typeof multiIdPlan> {
|
|
50
|
+
return multiIdPlan(
|
|
51
|
+
{ ...s.config, country: s.selectedCountry ?? s.config.country },
|
|
52
|
+
{ multiIdSlotIndex: s.multiIdSlotIndex, multiIdSlots: s.multiIdSlots },
|
|
53
|
+
s.serverConfig.status === 'ready' ? s.serverConfig.idTypes : [],
|
|
54
|
+
);
|
|
55
|
+
}
|
|
30
56
|
import { recordStep, resetStepLog } from '../lib/step-log';
|
|
57
|
+
import { multiIdPlan } from '../lib/multi-id';
|
|
58
|
+
import { resolveIdTypeDefinition } from '../config/idTypes';
|
|
31
59
|
import { buildVerifyRequest } from './submit';
|
|
60
|
+
import { resetBusinessCheck, runBusinessCheck } from './businessCheck';
|
|
61
|
+
import { startAttemptSession, watchSessionProgress } from './session';
|
|
32
62
|
import { applicantMediaCaptured, buildApplicantVerifyRequest } from './submitApplicant';
|
|
33
63
|
|
|
34
64
|
export * from './state';
|
|
@@ -65,9 +95,15 @@ export function createKycStore(
|
|
|
65
95
|
api,
|
|
66
96
|
|
|
67
97
|
currentStep: 'consent',
|
|
98
|
+
sessionId: null,
|
|
99
|
+
sessionUrl: null,
|
|
100
|
+
businessCheck: { ...EMPTY_BUSINESS_CHECK },
|
|
68
101
|
selectedCountry: null,
|
|
69
102
|
selectedIdType: null,
|
|
70
103
|
idNumber: null,
|
|
104
|
+
multiIdSlotIndex: 0,
|
|
105
|
+
multiIdSlots: [],
|
|
106
|
+
multiIdRestored: null,
|
|
71
107
|
mediaIds: {},
|
|
72
108
|
submissionResult: null,
|
|
73
109
|
serverConfig: serverConfig ?? INITIAL_SERVER_CONFIG,
|
|
@@ -106,6 +142,7 @@ export function createKycStore(
|
|
|
106
142
|
status: 'ready',
|
|
107
143
|
idTypes: res.idTypes,
|
|
108
144
|
branding,
|
|
145
|
+
geoCountry: res.geoCountry,
|
|
109
146
|
environment: res.environment,
|
|
110
147
|
fatal: false,
|
|
111
148
|
},
|
|
@@ -122,11 +159,90 @@ export function createKycStore(
|
|
|
122
159
|
// Changing country invalidates the ID choice: the same key can mean a
|
|
123
160
|
// different document (or none) elsewhere, so it is cleared rather than
|
|
124
161
|
// silently carried across.
|
|
125
|
-
set((s) =>
|
|
126
|
-
selectedCountry
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
162
|
+
set((s) => {
|
|
163
|
+
const same = s.selectedCountry === country;
|
|
164
|
+
return {
|
|
165
|
+
selectedCountry: country,
|
|
166
|
+
selectedIdType: same ? s.selectedIdType : null,
|
|
167
|
+
idNumber: same ? s.idNumber : null,
|
|
168
|
+
// A multi-ID run's committed slots belong to the country they were
|
|
169
|
+
// picked in; carrying them into a new one would offer an ID no
|
|
170
|
+
// register there can verify.
|
|
171
|
+
multiIdSlotIndex: same ? s.multiIdSlotIndex : 0,
|
|
172
|
+
multiIdSlots: same ? s.multiIdSlots : [],
|
|
173
|
+
multiIdRestored: same ? s.multiIdRestored : null,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
commitMultiIdSlot(nextStep, previews) {
|
|
179
|
+
set((s) => {
|
|
180
|
+
if (!s.selectedIdType) return {};
|
|
181
|
+
const slot = {
|
|
182
|
+
idType: s.selectedIdType,
|
|
183
|
+
...(s.idNumber ? { idNumber: s.idNumber } : {}),
|
|
184
|
+
...(s.mediaIds.documentFront ? { documentFront: s.mediaIds.documentFront } : {}),
|
|
185
|
+
...(s.mediaIds.documentBack ? { documentBack: s.mediaIds.documentBack } : {}),
|
|
186
|
+
...(s.mediaIds.documentFrontVideo
|
|
187
|
+
? { documentFrontVideo: s.mediaIds.documentFrontVideo }
|
|
188
|
+
: {}),
|
|
189
|
+
...(s.mediaIds.documentBackVideo
|
|
190
|
+
? { documentBackVideo: s.mediaIds.documentBackVideo }
|
|
191
|
+
: {}),
|
|
192
|
+
...(s.chipData ? { chipData: s.chipData } : {}),
|
|
193
|
+
...(previews?.front ? { documentFrontImage: previews.front } : {}),
|
|
194
|
+
...(previews?.back ? { documentBackImage: previews.back } : {}),
|
|
195
|
+
};
|
|
196
|
+
// The working evidence is cleared for the next check; the SELFIE and
|
|
197
|
+
// its video are run-level and deliberately untouched.
|
|
198
|
+
return {
|
|
199
|
+
multiIdSlots: [...s.multiIdSlots, slot],
|
|
200
|
+
multiIdSlotIndex: s.multiIdSlotIndex + 1,
|
|
201
|
+
selectedIdType: null,
|
|
202
|
+
idNumber: null,
|
|
203
|
+
multiIdRestored: null,
|
|
204
|
+
mediaIds: {
|
|
205
|
+
...s.mediaIds,
|
|
206
|
+
documentFront: undefined,
|
|
207
|
+
documentBack: undefined,
|
|
208
|
+
documentFrontVideo: undefined,
|
|
209
|
+
documentBackVideo: undefined,
|
|
210
|
+
},
|
|
211
|
+
// The chip belongs to the check just committed; the next one reads
|
|
212
|
+
// its own document (or none).
|
|
213
|
+
chipData: null,
|
|
214
|
+
documentScanPhase: 'front' as const,
|
|
215
|
+
currentStep: nextStep,
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
uncommitMultiIdSlot(step) {
|
|
221
|
+
set((s) => {
|
|
222
|
+
const last = s.multiIdSlots[s.multiIdSlots.length - 1];
|
|
223
|
+
if (!last) return {};
|
|
224
|
+
// Restore what that check captured — changing an earlier ID must not
|
|
225
|
+
// mean re-photographing a document that is still perfectly good.
|
|
226
|
+
return {
|
|
227
|
+
multiIdSlots: s.multiIdSlots.slice(0, -1),
|
|
228
|
+
multiIdSlotIndex: Math.max(s.multiIdSlots.length - 1, 0),
|
|
229
|
+
selectedIdType: last.idType,
|
|
230
|
+
idNumber: last.idNumber ?? null,
|
|
231
|
+
multiIdRestored: {
|
|
232
|
+
...(last.documentFrontImage ? { front: last.documentFrontImage } : {}),
|
|
233
|
+
...(last.documentBackImage ? { back: last.documentBackImage } : {}),
|
|
234
|
+
},
|
|
235
|
+
mediaIds: {
|
|
236
|
+
...s.mediaIds,
|
|
237
|
+
documentFront: last.documentFront,
|
|
238
|
+
documentBack: last.documentBack,
|
|
239
|
+
documentFrontVideo: last.documentFrontVideo,
|
|
240
|
+
documentBackVideo: last.documentBackVideo,
|
|
241
|
+
},
|
|
242
|
+
chipData: last.chipData ?? null,
|
|
243
|
+
currentStep: step,
|
|
244
|
+
};
|
|
245
|
+
});
|
|
130
246
|
},
|
|
131
247
|
|
|
132
248
|
setIdType(idType) {
|
|
@@ -163,11 +279,29 @@ export function createKycStore(
|
|
|
163
279
|
},
|
|
164
280
|
|
|
165
281
|
setContactVerified(channel, destination, token) {
|
|
282
|
+
set((s) => {
|
|
283
|
+
// A fresh proof clears its channel's "server refused this" flag.
|
|
284
|
+
const expired = (s.contact.expired ?? []).filter((c) => c !== channel);
|
|
285
|
+
return {
|
|
286
|
+
contact:
|
|
287
|
+
channel === 'email'
|
|
288
|
+
? { ...s.contact, emailAddress: destination, emailToken: token, expired }
|
|
289
|
+
: { ...s.contact, phoneNumber: destination, phoneToken: token, expired },
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
clearContactProofs(channels) {
|
|
295
|
+
// The server refused these proofs at submit (single-use tokens expire
|
|
296
|
+
// ~30 min after the OTP check, and session restore can resurrect a
|
|
297
|
+
// dead one). Drop the tokens, keep the destinations, flag the steps.
|
|
166
298
|
set((s) => ({
|
|
167
|
-
contact:
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
299
|
+
contact: {
|
|
300
|
+
...s.contact,
|
|
301
|
+
...(channels.includes('email') ? { emailToken: undefined } : {}),
|
|
302
|
+
...(channels.includes('phone') ? { phoneToken: undefined } : {}),
|
|
303
|
+
expired: channels,
|
|
304
|
+
},
|
|
171
305
|
}));
|
|
172
306
|
},
|
|
173
307
|
|
|
@@ -180,14 +314,54 @@ export function createKycStore(
|
|
|
180
314
|
}));
|
|
181
315
|
},
|
|
182
316
|
|
|
317
|
+
setSessionId(sessionId, sessionUrl) {
|
|
318
|
+
set({ sessionId, ...(sessionUrl !== undefined ? { sessionUrl } : {}) });
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
async checkBusiness() {
|
|
322
|
+
return runBusinessCheck(get, set);
|
|
323
|
+
},
|
|
324
|
+
|
|
183
325
|
setBusinessField(key, value) {
|
|
184
|
-
|
|
326
|
+
// Any change to WHICH company this is about invalidates the answer we
|
|
327
|
+
// hold, so the check never describes one business while the field names
|
|
328
|
+
// another. Everything the previous register told us about the old
|
|
329
|
+
// company goes with it — only what the REGISTER wrote: an applicant who
|
|
330
|
+
// typed their own address meant it. Clearing it also unblocks the next
|
|
331
|
+
// lookup, whose prefill only ever writes into an empty field, so
|
|
332
|
+
// leftovers were not merely stale, they were suppressing the real
|
|
333
|
+
// answer. Mirrors the web SDK's setDetails.
|
|
334
|
+
const identityChanged =
|
|
335
|
+
key === 'registrationNumber' || key === 'country' || key === 'product';
|
|
336
|
+
set((s) => {
|
|
337
|
+
const business = { ...s.business, [key]: value };
|
|
338
|
+
if (identityChanged) {
|
|
339
|
+
for (const prefilledKey of s.businessCheck.prefilled) {
|
|
340
|
+
if (prefilledKey !== key) (business as Record<string, unknown>)[prefilledKey] = '';
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return { business };
|
|
344
|
+
});
|
|
345
|
+
if (identityChanged) resetBusinessCheck(set);
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
applyBusinessPrefill(patch, prefilled) {
|
|
349
|
+
set((s) => ({
|
|
350
|
+
business: { ...s.business, ...patch },
|
|
351
|
+
businessCheck: { ...s.businessCheck, prefilled },
|
|
352
|
+
}));
|
|
185
353
|
},
|
|
186
354
|
|
|
187
355
|
setKeyPeople(rows) {
|
|
188
356
|
set((s) => ({ businessApplication: { ...s.businessApplication, keyPeople: rows } }));
|
|
189
357
|
},
|
|
190
358
|
|
|
359
|
+
setUboUnidentifiable(checked) {
|
|
360
|
+
set((s) => ({
|
|
361
|
+
businessApplication: { ...s.businessApplication, uboUnidentifiable: checked },
|
|
362
|
+
}));
|
|
363
|
+
},
|
|
364
|
+
|
|
191
365
|
setBusinessDocument(doc) {
|
|
192
366
|
set((s) => ({
|
|
193
367
|
businessApplication: {
|
|
@@ -276,6 +450,26 @@ export function createKycStore(
|
|
|
276
450
|
|
|
277
451
|
nextStep() {
|
|
278
452
|
const next = nextStepAfter(get().currentStep, get());
|
|
453
|
+
|
|
454
|
+
// Multi-ID: the run walks the capture leg once PER ID. Intercepted here
|
|
455
|
+
// rather than in each screen because it is one rule — "the applicant
|
|
456
|
+
// finished this check" — and the leg has several exits (a number-only
|
|
457
|
+
// ID leaves from id-input, a document ID from document-capture or the
|
|
458
|
+
// chip read after it).
|
|
459
|
+
const plan = multiIdPlanFor(get());
|
|
460
|
+
if (plan && ID_EVIDENCE_STEPS.has(get().currentStep) && !ID_EVIDENCE_STEPS.has(next)) {
|
|
461
|
+
const previews = get().multiIdRestored ?? undefined;
|
|
462
|
+
if (!plan.last) {
|
|
463
|
+
// Another ID to go: commit this one and hand the picker back.
|
|
464
|
+
get().commitMultiIdSlot('id-type', previews);
|
|
465
|
+
emitStepChange('id-type');
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
// The final check: commit it, then carry on to the shared selfie.
|
|
469
|
+
get().commitMultiIdSlot(next, previews);
|
|
470
|
+
emitStepChange(next);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
279
473
|
// Leaving document capture is the moment the chip step either appears or
|
|
280
474
|
// silently does not. Four independent gates can remove it and a missing
|
|
281
475
|
// step looks the same however it went missing, so say which one.
|
|
@@ -294,6 +488,22 @@ export function createKycStore(
|
|
|
294
488
|
},
|
|
295
489
|
|
|
296
490
|
previousStep() {
|
|
491
|
+
// Multi-ID: stepping back from the picker means re-doing the PREVIOUS
|
|
492
|
+
// check, not leaving the flow. The slot is uncommitted so its ID number
|
|
493
|
+
// and captures come back — changing an earlier ID must not mean
|
|
494
|
+
// re-photographing a document that is still perfectly good.
|
|
495
|
+
const slots = get().multiIdSlots;
|
|
496
|
+
if (get().currentStep === 'id-type' && slots.length > 0) {
|
|
497
|
+
const last = slots[slots.length - 1]!;
|
|
498
|
+
const def = resolveIdTypeDefinition(
|
|
499
|
+
get().selectedCountry ?? get().config.country ?? '',
|
|
500
|
+
last.idType,
|
|
501
|
+
);
|
|
502
|
+
const step: KYCStep = def?.requiresDocumentCapture === false ? 'id-input' : 'document-capture';
|
|
503
|
+
get().uncommitMultiIdSlot(step);
|
|
504
|
+
emitStepChange(step);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
297
507
|
const prev = previousStepBefore(get().currentStep, get());
|
|
298
508
|
if (prev !== get().currentStep) {
|
|
299
509
|
set({ currentStep: prev, immersiveCapture: false, navDirection: 'back' });
|
|
@@ -322,7 +532,7 @@ export function createKycStore(
|
|
|
322
532
|
const res = await withRetry(() => api.verify(request), { onRetry });
|
|
323
533
|
const result: KYCSubmissionResult = {
|
|
324
534
|
verificationId: res.verificationId,
|
|
325
|
-
status: '
|
|
535
|
+
status: 'processing',
|
|
326
536
|
};
|
|
327
537
|
set({
|
|
328
538
|
submissionResult: result,
|
|
@@ -359,18 +569,24 @@ export function createKycStore(
|
|
|
359
569
|
// already sitting on 'consent' (the subscribe below only fires on
|
|
360
570
|
// change, and recordStep dedupes if it fires too).
|
|
361
571
|
resetStepLog();
|
|
362
|
-
recordStep('consent');
|
|
572
|
+
recordStep('consent', multiIdSlotOf(get()), selectedIdTypeOf(get()));
|
|
363
573
|
set({
|
|
364
574
|
currentStep: 'consent',
|
|
365
575
|
selectedCountry: null,
|
|
366
576
|
selectedIdType: null,
|
|
367
577
|
idNumber: null,
|
|
578
|
+
multiIdSlotIndex: 0,
|
|
579
|
+
multiIdSlots: [],
|
|
580
|
+
multiIdRestored: null,
|
|
368
581
|
mediaIds: {},
|
|
369
582
|
submissionResult: null,
|
|
370
583
|
documentScanPhase: 'front',
|
|
371
584
|
documentCapturePhase: 'front',
|
|
372
585
|
immersiveCapture: false,
|
|
373
586
|
flashPaint: null,
|
|
587
|
+
sessionId: null,
|
|
588
|
+
sessionUrl: null,
|
|
589
|
+
businessCheck: { ...EMPTY_BUSINESS_CHECK },
|
|
374
590
|
questionnaireAnswers: {},
|
|
375
591
|
contact: {},
|
|
376
592
|
contactChallenge: null,
|
|
@@ -395,7 +611,9 @@ export function createKycStore(
|
|
|
395
611
|
// collapses consecutive duplicates). Rides the submission as
|
|
396
612
|
// metadata.device.stepLog for the dashboard timeline.
|
|
397
613
|
store.subscribe((s, prev) => {
|
|
398
|
-
if (s.currentStep !== prev.currentStep)
|
|
614
|
+
if (s.currentStep !== prev.currentStep) {
|
|
615
|
+
recordStep(s.currentStep, multiIdSlotOf(s), selectedIdTypeOf(s));
|
|
616
|
+
}
|
|
399
617
|
});
|
|
400
618
|
|
|
401
619
|
// A fresh store IS a fresh session: the runtime provider creates one per
|
|
@@ -404,7 +622,16 @@ export function createKycStore(
|
|
|
404
622
|
// subscription above only fires on CHANGE, and a new store already sits on
|
|
405
623
|
// 'consent').
|
|
406
624
|
resetStepLog();
|
|
407
|
-
recordStep(
|
|
625
|
+
recordStep(
|
|
626
|
+
store.getState().currentStep,
|
|
627
|
+
multiIdSlotOf(store.getState()),
|
|
628
|
+
selectedIdTypeOf(store.getState()),
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
// The attempt session: minted at launch (a fresh store IS a fresh attempt),
|
|
632
|
+
// with progress written as the user moves. Both best-effort by contract.
|
|
633
|
+
startAttemptSession(store);
|
|
634
|
+
watchSessionProgress(store);
|
|
408
635
|
|
|
409
636
|
return store;
|
|
410
637
|
}
|
|
@@ -19,6 +19,8 @@ export interface ServerConfigState {
|
|
|
19
19
|
status: ServerConfigStatus;
|
|
20
20
|
idTypes: SdkConfigIdType[];
|
|
21
21
|
branding?: SdkConfigBranding;
|
|
22
|
+
/** The visitor's country from their IP — a default, never evidence. */
|
|
23
|
+
geoCountry?: string | null;
|
|
22
24
|
environment?: 'DEVELOPMENT' | 'SANDBOX' | 'PRODUCTION';
|
|
23
25
|
/** HTTP status of a failed config fetch (if any). */
|
|
24
26
|
statusCode?: number;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { KycState, KycStore } from './state';
|
|
2
|
+
import { getStepLog } from '../lib/step-log';
|
|
3
|
+
import { emptyKeyPerson, type KeyPersonEntry } from '../config/keyPeople';
|
|
4
|
+
import type { SessionStartResponse } from '../services/api-types';
|
|
5
|
+
import { persistentDeviceId } from '../services/fingerprint-sources';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// The attempt SESSION: minting at launch, and progress writes as the user moves.
|
|
9
|
+
//
|
|
10
|
+
// Ported from the web SDK (MyazaKYC.tsx beginSession + useSessionProgress),
|
|
11
|
+
// with the same contracts:
|
|
12
|
+
//
|
|
13
|
+
// Best-effort throughout. Sessions power resumability, the dashboard's live
|
|
14
|
+
// attempt view, session webhooks, and the registry check at selection —
|
|
15
|
+
// verifying is never conditional on one existing, so every failure here is
|
|
16
|
+
// swallowed.
|
|
17
|
+
//
|
|
18
|
+
// Untouched progress is never written. The presence of stored progress IS
|
|
19
|
+
// "they started"; a save-on-mount would make every opened flow look started,
|
|
20
|
+
// and the server tells an abandoned first screen from a worked-through form
|
|
21
|
+
// by exactly this absence.
|
|
22
|
+
//
|
|
23
|
+
// A remount RESTORES the user to where they were, like web: the resumed
|
|
24
|
+
// session's stored progress hydrates the store (restoreAttemptProgress),
|
|
25
|
+
// so their step, captures and typed data survive an app restart.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const SAVE_DEBOUNCE_MS = 800;
|
|
29
|
+
|
|
30
|
+
// Collapses concurrent mints into one request per launch (mirrors the web
|
|
31
|
+
// SDK's start-session-once). Dev-mode double-invocation fired /session/start
|
|
32
|
+
// twice ~600ms apart; without an externalUserId the server has nothing to
|
|
33
|
+
// resume by, so the second call minted a SECOND session — the flow adopted one
|
|
34
|
+
// and the orphan sat on the org's list forever as "Not started". Short window
|
|
35
|
+
// on purpose: it collapses a double-invoke, never caches sessions, and a
|
|
36
|
+
// failure clears immediately so a retry is a real retry.
|
|
37
|
+
const inflightStarts = new Map<string, Promise<SessionStartResponse>>();
|
|
38
|
+
|
|
39
|
+
/** Mint (or resume) the attempt session and remember its id. Fire-and-forget. */
|
|
40
|
+
export function startAttemptSession(store: KycStore): void {
|
|
41
|
+
const s = store.getState();
|
|
42
|
+
if (s.sessionId) return;
|
|
43
|
+
const externalUserId = s.config.userId ?? s.config.metadata?.['userId'];
|
|
44
|
+
const key = `${s.config.apiKey}|${s.config.workflowId ?? ''}|${externalUserId ?? ''}`;
|
|
45
|
+
let start = inflightStarts.get(key);
|
|
46
|
+
if (!start) {
|
|
47
|
+
// The device id is the anonymous-mount resume fallback: without a userId
|
|
48
|
+
// the server has nothing else to find the previous attempt by, and every
|
|
49
|
+
// app relaunch minted a fresh session. Hashed server-side before storage.
|
|
50
|
+
start = persistentDeviceId().then((deviceRef) =>
|
|
51
|
+
s.api.startSession({
|
|
52
|
+
externalUserId,
|
|
53
|
+
...(deviceRef ? { deviceRef } : {}),
|
|
54
|
+
...(s.config.workflowId ? { workflowId: s.config.workflowId } : {}),
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
inflightStarts.set(key, start);
|
|
58
|
+
start.then(
|
|
59
|
+
() => setTimeout(() => inflightStarts.delete(key), 2000),
|
|
60
|
+
() => inflightStarts.delete(key),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
void start
|
|
64
|
+
.then((res) => {
|
|
65
|
+
store.getState().setSessionId(res.sessionId, res.url ?? null);
|
|
66
|
+
// Resuming: put the user back where they were, exactly as web does.
|
|
67
|
+
// Media references are pruned server-side of anything expired, so a
|
|
68
|
+
// restored capture slot is one whose bytes genuinely still exist.
|
|
69
|
+
if (res.progress) restoreAttemptProgress(store, res.progress);
|
|
70
|
+
})
|
|
71
|
+
.catch(() => undefined);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Hydrate the store from a resumed session's stored progress — the RN mirror
|
|
76
|
+
* of the web reducer's RESTORE_PROGRESS. Only fields the snapshot carries are
|
|
77
|
+
* touched; everything else keeps its launch value, so a partial or old
|
|
78
|
+
* snapshot degrades to restoring less, never to breaking the flow.
|
|
79
|
+
*/
|
|
80
|
+
export function restoreAttemptProgress(
|
|
81
|
+
store: KycStore,
|
|
82
|
+
progress: NonNullable<SessionStartResponse['progress']>,
|
|
83
|
+
): void {
|
|
84
|
+
const s = store.getState();
|
|
85
|
+
const d = (progress.data ?? {}) as Record<string, unknown>;
|
|
86
|
+
|
|
87
|
+
const app = d['businessApplication'] as
|
|
88
|
+
| (Partial<KycState['businessApplication']> & { keyPeople?: Array<Record<string, unknown>> })
|
|
89
|
+
| undefined;
|
|
90
|
+
|
|
91
|
+
store.setState({
|
|
92
|
+
// The terminal step is a RESULT, not a position. A submission that FAILED
|
|
93
|
+
// still writes `submitted` as the last step reached, so resuming there
|
|
94
|
+
// submits again, fails the same way and writes it again: closing the app
|
|
95
|
+
// and reopening lands straight back on the error with no way forward.
|
|
96
|
+
// Starting the flow again also re-resolves the workflow, so an applicant
|
|
97
|
+
// caught mid-flight across a republish walks the CURRENT steps rather than
|
|
98
|
+
// submitting against rules they were never shown.
|
|
99
|
+
...(typeof progress.step === 'string' && progress.step !== 'submitted'
|
|
100
|
+
? { currentStep: progress.step as KycState['currentStep'] }
|
|
101
|
+
: {}),
|
|
102
|
+
...(progress.mediaIds ? { mediaIds: { ...s.mediaIds, ...progress.mediaIds } } : {}),
|
|
103
|
+
...(typeof d['selectedCountry'] === 'string'
|
|
104
|
+
? { selectedCountry: d['selectedCountry'] as string }
|
|
105
|
+
: {}),
|
|
106
|
+
...(typeof d['selectedIdType'] === 'string'
|
|
107
|
+
? { selectedIdType: d['selectedIdType'] as KycState['selectedIdType'] }
|
|
108
|
+
: {}),
|
|
109
|
+
...(typeof d['idNumber'] === 'string' ? { idNumber: d['idNumber'] as string } : {}),
|
|
110
|
+
...(d['business'] && typeof d['business'] === 'object'
|
|
111
|
+
? { business: { ...s.business, ...(d['business'] as object) } }
|
|
112
|
+
: {}),
|
|
113
|
+
...(app
|
|
114
|
+
? {
|
|
115
|
+
businessApplication: {
|
|
116
|
+
...s.businessApplication,
|
|
117
|
+
...app,
|
|
118
|
+
// Rows saved BEFORE the sectioned redesign predate `roles`, `title`
|
|
119
|
+
// and `owners`; a restored attempt must not hand the cards a shape
|
|
120
|
+
// they cannot read. Same normalization as web's RESTORE_PROGRESS.
|
|
121
|
+
...(app.keyPeople
|
|
122
|
+
? {
|
|
123
|
+
keyPeople: app.keyPeople.map((row) => ({
|
|
124
|
+
...emptyKeyPerson(),
|
|
125
|
+
...row,
|
|
126
|
+
roles:
|
|
127
|
+
Array.isArray(row['roles']) && (row['roles'] as unknown[]).length > 0
|
|
128
|
+
? (row['roles'] as KeyPersonEntry['roles'])
|
|
129
|
+
: [(row['role'] as KeyPersonEntry['role']) ?? 'director'],
|
|
130
|
+
title: typeof row['title'] === 'string' ? (row['title'] as string) : '',
|
|
131
|
+
owners: Array.isArray(row['owners'])
|
|
132
|
+
? (row['owners'] as KeyPersonEntry['owners'])
|
|
133
|
+
: [],
|
|
134
|
+
})),
|
|
135
|
+
}
|
|
136
|
+
: {}),
|
|
137
|
+
} as KycState['businessApplication'],
|
|
138
|
+
}
|
|
139
|
+
: {}),
|
|
140
|
+
...(d['contact'] && typeof d['contact'] === 'object'
|
|
141
|
+
? { contact: { ...s.contact, ...(d['contact'] as object) } }
|
|
142
|
+
: {}),
|
|
143
|
+
...(d['questionnaireAnswers'] && typeof d['questionnaireAnswers'] === 'object'
|
|
144
|
+
? {
|
|
145
|
+
questionnaireAnswers: {
|
|
146
|
+
...s.questionnaireAnswers,
|
|
147
|
+
...(d['questionnaireAnswers'] as object),
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
: {}),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The progress snapshot the server stores — mirrors the web SDK's shape, so
|
|
155
|
+
* the dashboard's attempt page reads both without branching. */
|
|
156
|
+
export function progressFromState(s: ReturnType<KycStore['getState']>): Record<string, unknown> {
|
|
157
|
+
const mediaIds = Object.fromEntries(
|
|
158
|
+
Object.entries(s.mediaIds ?? {}).filter(([, v]) => typeof v === 'string' && v),
|
|
159
|
+
);
|
|
160
|
+
return {
|
|
161
|
+
step: s.currentStep,
|
|
162
|
+
stepLog: getStepLog(),
|
|
163
|
+
mediaIds,
|
|
164
|
+
data: {
|
|
165
|
+
selectedCountry: s.selectedCountry ?? undefined,
|
|
166
|
+
selectedIdType: s.selectedIdType ?? undefined,
|
|
167
|
+
idNumber: s.idNumber || undefined,
|
|
168
|
+
business: s.business,
|
|
169
|
+
businessApplication: s.businessApplication,
|
|
170
|
+
contact: s.contact,
|
|
171
|
+
questionnaireAnswers: s.questionnaireAnswers,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Nothing here needs saving until they move off the opening screen. */
|
|
177
|
+
export function isUntouchedProgress(payload: Record<string, unknown>): boolean {
|
|
178
|
+
if (payload['step'] !== 'consent') return false;
|
|
179
|
+
return Object.keys((payload['mediaIds'] as object) ?? {}).length === 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Persist progress as the user advances. Debounced, deduped by snapshot, and
|
|
184
|
+
* a no-op until the session exists.
|
|
185
|
+
*/
|
|
186
|
+
export function watchSessionProgress(store: KycStore): () => void {
|
|
187
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
188
|
+
let lastSaved = '';
|
|
189
|
+
|
|
190
|
+
const unsubscribe = store.subscribe(() => {
|
|
191
|
+
if (timer) clearTimeout(timer);
|
|
192
|
+
timer = setTimeout(() => {
|
|
193
|
+
const s = store.getState();
|
|
194
|
+
if (!s.sessionId) return;
|
|
195
|
+
const payload = progressFromState(s);
|
|
196
|
+
if (isUntouchedProgress(payload)) return;
|
|
197
|
+
const fingerprint = JSON.stringify(payload);
|
|
198
|
+
if (fingerprint === lastSaved) return;
|
|
199
|
+
lastSaved = fingerprint;
|
|
200
|
+
void s.api.saveProgress(s.sessionId, payload).catch(() => undefined);
|
|
201
|
+
}, SAVE_DEBOUNCE_MS);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return () => {
|
|
205
|
+
unsubscribe();
|
|
206
|
+
if (timer) clearTimeout(timer);
|
|
207
|
+
};
|
|
208
|
+
}
|