@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.
Files changed (129) hide show
  1. package/android/build.gradle +35 -3
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +75 -0
  3. package/ios/HybridMyazaFaceDetector.swift +8 -0
  4. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  5. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  6. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  7. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  8. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  9. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  10. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  11. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  12. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  13. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  14. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  15. package/package.json +1 -2
  16. package/src/MyazaKYC.tsx +12 -0
  17. package/src/components/BrandBar.tsx +137 -0
  18. package/src/components/DashedBorder.tsx +55 -0
  19. package/src/components/DialCodePicker.tsx +25 -24
  20. package/src/components/DocumentReviewSide.tsx +34 -14
  21. package/src/components/Icon.tsx +14 -0
  22. package/src/components/KycSheet.tsx +39 -140
  23. package/src/components/MediaSourceSheet.tsx +7 -37
  24. package/src/components/MultiIdProgress.tsx +107 -0
  25. package/src/components/MyazaDateField.tsx +6 -20
  26. package/src/components/MyazaInput.tsx +23 -4
  27. package/src/components/MyazaSelect.tsx +17 -40
  28. package/src/components/OwnershipSlider.tsx +102 -0
  29. package/src/components/PhoneNumberInput.tsx +37 -4
  30. package/src/components/SandboxBanner.tsx +92 -0
  31. package/src/components/StaggerIn.tsx +63 -0
  32. package/src/components/StepHeader.tsx +15 -2
  33. package/src/components/StepView.tsx +26 -0
  34. package/src/components/glass/ChromeGlass.tsx +65 -0
  35. package/src/components/glass/FloatingSheet.tsx +242 -0
  36. package/src/components/glass/GlassSheet.tsx +50 -0
  37. package/src/components/glass/GlassSurface.tsx +31 -3
  38. package/src/components/stepHeaderMeta.tsx +3 -1
  39. package/src/components/viewfinder/ImmersiveBottomBar.tsx +28 -17
  40. package/src/components/viewfinder/ImmersiveControls.tsx +26 -14
  41. package/src/components/viewfinder/ViewfinderControls.tsx +29 -7
  42. package/src/config/business.ts +6 -6
  43. package/src/config/businessDetailsValidity.ts +52 -0
  44. package/src/config/businessPrefill.ts +72 -0
  45. package/src/config/businessSteps.ts +14 -3
  46. package/src/config/keyPeople.ts +111 -7
  47. package/src/config/keyPeopleCorporate.ts +19 -0
  48. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  49. package/src/config/keyPeopleOwners.ts +26 -0
  50. package/src/config/keyPeoplePrefill.ts +138 -0
  51. package/src/config/keyPeopleSectionDefs.ts +65 -0
  52. package/src/config/keyPeopleSections.ts +167 -0
  53. package/src/config/registrationHint.ts +1 -1
  54. package/src/config/stepOrder.ts +24 -4
  55. package/src/config/theme.ts +8 -2
  56. package/src/config/website.ts +36 -0
  57. package/src/config/workflowMerge.ts +5 -0
  58. package/src/emrtd/activeAuth.ts +100 -0
  59. package/src/emrtd/extras.ts +76 -0
  60. package/src/emrtd/files.ts +5 -0
  61. package/src/emrtd/index.ts +1 -0
  62. package/src/emrtd/read.ts +8 -1
  63. package/src/emrtd/session.ts +41 -0
  64. package/src/emrtd/stages.ts +11 -0
  65. package/src/lib/contact-recovery.ts +44 -0
  66. package/src/lib/multi-id.ts +210 -0
  67. package/src/lib/resubmit.ts +130 -0
  68. package/src/lib/screen-corners.ts +50 -0
  69. package/src/lib/step-log.ts +24 -3
  70. package/src/lib/use-multi-id-plan.ts +29 -0
  71. package/src/liveness/useModelReady.ts +65 -0
  72. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  73. package/src/screens/ApplicantRoleStep.tsx +9 -5
  74. package/src/screens/BusinessCheckPanel.tsx +89 -0
  75. package/src/screens/BusinessDetailsFields.tsx +109 -0
  76. package/src/screens/BusinessDetailsStep.tsx +131 -145
  77. package/src/screens/BusinessDocumentSlot.tsx +2 -3
  78. package/src/screens/BusinessDocumentsStep.tsx +1 -1
  79. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  80. package/src/screens/BusinessPickedCard.tsx +86 -0
  81. package/src/screens/BusinessPickedSection.tsx +51 -0
  82. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  83. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  84. package/src/screens/BusinessSearch.tsx +199 -0
  85. package/src/screens/BusinessSearchResults.tsx +115 -0
  86. package/src/screens/CompanyInfoFields.tsx +86 -51
  87. package/src/screens/ConsentStep.tsx +31 -0
  88. package/src/screens/ContactDestinationField.tsx +1 -0
  89. package/src/screens/ContactVerificationStep.tsx +34 -3
  90. package/src/screens/IdTypeStep.tsx +12 -5
  91. package/src/screens/KeepLinksSheet.tsx +88 -0
  92. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  93. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  94. package/src/screens/KeyPeoplePending.tsx +154 -0
  95. package/src/screens/KeyPeopleSection.tsx +162 -0
  96. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  97. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  98. package/src/screens/KeyPersonCard.tsx +36 -4
  99. package/src/screens/KeyPersonForm.tsx +195 -36
  100. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  101. package/src/screens/KeyPersonOwners.tsx +109 -0
  102. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  103. package/src/screens/KeyPersonSheet.tsx +92 -108
  104. package/src/screens/LivenessStep.tsx +18 -0
  105. package/src/screens/NfcStep.tsx +14 -0
  106. package/src/screens/ProofOfAddressStep.tsx +6 -3
  107. package/src/screens/SubmittedStep.tsx +66 -10
  108. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  109. package/src/screens/useAwaitingPeople.ts +90 -0
  110. package/src/services/api-types.ts +158 -2
  111. package/src/services/api-verify-types.ts +53 -1
  112. package/src/services/api.ts +104 -0
  113. package/src/services/deviceMetadata.ts +1 -1
  114. package/src/services/errors.ts +10 -2
  115. package/src/services/workflowGate.ts +1 -0
  116. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  117. package/src/store/businessCheck.ts +113 -0
  118. package/src/store/derive.ts +15 -0
  119. package/src/store/kycStore.ts +241 -14
  120. package/src/store/serverConfig.ts +2 -0
  121. package/src/store/session.ts +208 -0
  122. package/src/store/state.ts +155 -2
  123. package/src/store/submit.ts +63 -13
  124. package/src/store/submitApplicant.ts +5 -11
  125. package/src/types/business.ts +23 -2
  126. package/src/types/config.ts +29 -3
  127. package/src/types/verification.ts +1 -1
  128. package/src/types/workflow.ts +15 -0
  129. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -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: country,
127
- selectedIdType: s.selectedCountry === country ? s.selectedIdType : null,
128
- idNumber: s.selectedCountry === country ? s.idNumber : null,
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
- channel === 'email'
169
- ? { ...s.contact, emailAddress: destination, emailToken: token }
170
- : { ...s.contact, phoneNumber: destination, phoneToken: token },
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
- set((s) => ({ business: { ...s.business, [key]: value } }));
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: 'pending',
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) recordStep(s.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(store.getState().currentStep);
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
+ }