@myazahq/kyc-sdk-react-native 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/README.md +151 -5
  2. package/android/build.gradle +35 -3
  3. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
  4. package/app.plugin.js +89 -8
  5. package/ios/HybridMyazaFaceDetector.swift +16 -3
  6. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  7. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  8. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  9. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  10. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  11. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  12. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  13. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  14. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  15. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  16. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  17. package/package.json +14 -2
  18. package/src/MyazaBiometricAuth.tsx +119 -0
  19. package/src/MyazaKYC.tsx +12 -0
  20. package/src/components/CountryField.tsx +8 -0
  21. package/src/components/CountryRegionPicker.tsx +184 -0
  22. package/src/components/DashedBorder.tsx +55 -0
  23. package/src/components/DialCodePicker.tsx +53 -67
  24. package/src/components/DialCodeRow.tsx +102 -0
  25. package/src/components/FramedMapPicker.tsx +122 -0
  26. package/src/components/GeoBadge.tsx +34 -0
  27. package/src/components/Icon.tsx +2 -176
  28. package/src/components/KycFlow.tsx +60 -32
  29. package/src/components/LineSkeleton.tsx +100 -0
  30. package/src/components/MapChrome.tsx +67 -0
  31. package/src/components/MapPinMarker.tsx +54 -0
  32. package/src/components/MapPinPicker.tsx +179 -0
  33. package/src/components/MilestoneTrack.tsx +155 -0
  34. package/src/components/MultiIdProgress.tsx +107 -0
  35. package/src/components/MyazaInput.tsx +39 -4
  36. package/src/components/MyazaSelect.tsx +2 -1
  37. package/src/components/OptionRow.tsx +22 -3
  38. package/src/components/OwnershipSlider.tsx +102 -0
  39. package/src/components/PhoneNumberInput.tsx +45 -4
  40. package/src/components/PoweredBy.tsx +5 -5
  41. package/src/components/PresenceBlocks.tsx +153 -0
  42. package/src/components/StaggerIn.tsx +63 -0
  43. package/src/components/StepView.tsx +43 -0
  44. package/src/components/StickyActions.tsx +56 -0
  45. package/src/components/dialCodeRows.ts +64 -0
  46. package/src/components/glass/FloatingSheet.tsx +73 -21
  47. package/src/components/glass/GlassSheet.tsx +14 -2
  48. package/src/components/icon-map.ts +176 -0
  49. package/src/components/stepHeaderMeta.tsx +45 -5
  50. package/src/config/addressCollection.ts +125 -0
  51. package/src/config/biometricOptions.ts +100 -0
  52. package/src/config/business.ts +26 -7
  53. package/src/config/businessDetailsValidity.ts +52 -0
  54. package/src/config/businessPrefill.ts +72 -0
  55. package/src/config/businessSteps.ts +20 -3
  56. package/src/config/consentStep.ts +19 -0
  57. package/src/config/keyPeople.ts +111 -7
  58. package/src/config/keyPeopleCorporate.ts +19 -0
  59. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  60. package/src/config/keyPeopleOwners.ts +26 -0
  61. package/src/config/keyPeoplePrefill.ts +138 -0
  62. package/src/config/keyPeopleSectionDefs.ts +65 -0
  63. package/src/config/keyPeopleSections.ts +167 -0
  64. package/src/config/proofOfAddress.ts +54 -11
  65. package/src/config/regions.ts +24 -0
  66. package/src/config/registrationHint.ts +1 -1
  67. package/src/config/stepOrder.ts +100 -6
  68. package/src/config/theme.ts +8 -2
  69. package/src/config/uploadLimits.ts +35 -0
  70. package/src/config/website.ts +36 -0
  71. package/src/config/workflowMerge.ts +16 -5
  72. package/src/emrtd/activeAuth.ts +100 -0
  73. package/src/emrtd/extras.ts +76 -0
  74. package/src/emrtd/files.ts +5 -0
  75. package/src/emrtd/index.ts +1 -0
  76. package/src/emrtd/read.ts +8 -1
  77. package/src/emrtd/session.ts +41 -0
  78. package/src/emrtd/stages.ts +11 -0
  79. package/src/index.ts +54 -1
  80. package/src/lib/address-current-location.ts +171 -0
  81. package/src/lib/address-field-modes.ts +119 -0
  82. package/src/lib/address-flow.ts +172 -0
  83. package/src/lib/address-helpers.ts +48 -0
  84. package/src/lib/address-line.ts +76 -0
  85. package/src/lib/address-pin-move.ts +97 -0
  86. package/src/lib/address-step-recovery.ts +63 -0
  87. package/src/lib/authed-image.ts +78 -0
  88. package/src/lib/biometric-auth.ts +52 -0
  89. package/src/lib/biometric-copy.ts +51 -0
  90. package/src/lib/captureRing.ts +83 -0
  91. package/src/lib/contact-recovery.ts +44 -0
  92. package/src/lib/country-adoption.ts +89 -0
  93. package/src/lib/inferred-country.ts +65 -0
  94. package/src/lib/livenessLayout.ts +49 -0
  95. package/src/lib/map-frame.ts +159 -0
  96. package/src/lib/map-tiles.ts +143 -0
  97. package/src/lib/multi-id.ts +210 -0
  98. package/src/lib/poa-country-gate.ts +27 -0
  99. package/src/lib/resubmit.ts +130 -0
  100. package/src/lib/result-copy.ts +116 -0
  101. package/src/lib/result-wait.ts +53 -0
  102. package/src/lib/review-map-surface.ts +26 -0
  103. package/src/lib/scope.ts +31 -0
  104. package/src/lib/screen-corners.ts +50 -0
  105. package/src/lib/selfie-upload-wait.ts +79 -0
  106. package/src/lib/step-log.ts +24 -3
  107. package/src/lib/street-view-fov.ts +42 -0
  108. package/src/lib/use-multi-id-plan.ts +29 -0
  109. package/src/lib/webview-available.ts +47 -0
  110. package/src/liveness/useLiveness.ts +6 -1
  111. package/src/liveness/useModelReady.ts +65 -0
  112. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  113. package/src/presence/background-math.ts +100 -0
  114. package/src/presence/background-store.ts +82 -0
  115. package/src/presence/background.ts +165 -0
  116. package/src/presence/foreground-service.ts +193 -0
  117. package/src/presence/fs.ts +51 -0
  118. package/src/presence/geofence.ts +41 -0
  119. package/src/presence/math.ts +44 -0
  120. package/src/presence/post.ts +40 -0
  121. package/src/presence/report.ts +87 -0
  122. package/src/presence/sampler.ts +110 -0
  123. package/src/presence/status.ts +92 -0
  124. package/src/presence/store.ts +94 -0
  125. package/src/presence/tier.ts +36 -0
  126. package/src/presence/watch-wait.ts +81 -0
  127. package/src/screens/AddressCountryControl.tsx +115 -0
  128. package/src/screens/ApplicantRoleStep.tsx +9 -5
  129. package/src/screens/BusinessCheckPanel.tsx +89 -0
  130. package/src/screens/BusinessDetailsFields.tsx +112 -0
  131. package/src/screens/BusinessDetailsStep.tsx +133 -145
  132. package/src/screens/BusinessDocumentSlot.tsx +5 -5
  133. package/src/screens/BusinessDocumentsStep.tsx +5 -56
  134. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  135. package/src/screens/BusinessPickedCard.tsx +86 -0
  136. package/src/screens/BusinessPickedSection.tsx +51 -0
  137. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  138. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  139. package/src/screens/BusinessSearch.tsx +199 -0
  140. package/src/screens/BusinessSearchResults.tsx +115 -0
  141. package/src/screens/CompanyInfoFields.tsx +91 -51
  142. package/src/screens/ConsentStep.tsx +37 -17
  143. package/src/screens/ContactActions.tsx +54 -0
  144. package/src/screens/ContactDestinationField.tsx +5 -0
  145. package/src/screens/ContactEntryPanel.tsx +75 -0
  146. package/src/screens/ContactVerificationStep.tsx +43 -42
  147. package/src/screens/CountrySelectStep.tsx +13 -147
  148. package/src/screens/IdTypeStep.tsx +12 -5
  149. package/src/screens/KeepLinksSheet.tsx +88 -0
  150. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  151. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  152. package/src/screens/KeyPeoplePending.tsx +154 -0
  153. package/src/screens/KeyPeopleSection.tsx +162 -0
  154. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  155. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  156. package/src/screens/KeyPersonCard.tsx +36 -4
  157. package/src/screens/KeyPersonForm.tsx +195 -36
  158. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  159. package/src/screens/KeyPersonOwners.tsx +109 -0
  160. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  161. package/src/screens/KeyPersonSheet.tsx +92 -108
  162. package/src/screens/LivenessAvatar.tsx +15 -3
  163. package/src/screens/LivenessStep.tsx +172 -52
  164. package/src/screens/NfcStep.tsx +14 -0
  165. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  166. package/src/screens/ProofOfAddressParts.tsx +151 -0
  167. package/src/screens/ProofOfAddressStep.tsx +58 -96
  168. package/src/screens/SubmittedBadge.tsx +25 -0
  169. package/src/screens/SubmittedError.tsx +64 -0
  170. package/src/screens/SubmittedResult.tsx +117 -0
  171. package/src/screens/SubmittedStep.tsx +82 -119
  172. package/src/screens/SubmittedSuccess.tsx +127 -0
  173. package/src/screens/SubmittedWaiting.tsx +45 -0
  174. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  175. package/src/screens/address/AddressIntroGate.tsx +148 -0
  176. package/src/screens/address/AddressMapStub.tsx +50 -0
  177. package/src/screens/address/AddressPinStep.tsx +178 -0
  178. package/src/screens/address/AddressReviewStep.tsx +162 -0
  179. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  180. package/src/screens/address/AddressSearchField.tsx +115 -0
  181. package/src/screens/address/AddressSearchStep.tsx +94 -0
  182. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  183. package/src/screens/address/DetailsSheet.tsx +175 -0
  184. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  185. package/src/screens/address/EntranceDropzone.tsx +178 -0
  186. package/src/screens/address/EntranceFraming.tsx +66 -0
  187. package/src/screens/address/EntrancePills.tsx +60 -0
  188. package/src/screens/address/FramedStreetView.tsx +155 -0
  189. package/src/screens/address/IntroDisclosures.tsx +210 -0
  190. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  191. package/src/screens/address/PinSummaryRow.tsx +105 -0
  192. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  193. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  194. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  195. package/src/screens/address/SearchResults.tsx +105 -0
  196. package/src/screens/address/SearchScreen.tsx +176 -0
  197. package/src/screens/address/SkipForNow.tsx +43 -0
  198. package/src/screens/address/StreetViewChrome.tsx +81 -0
  199. package/src/screens/address/detail-values.ts +22 -0
  200. package/src/screens/address/fix-source.ts +27 -0
  201. package/src/screens/address/index.ts +8 -0
  202. package/src/screens/address/meta.ts +44 -0
  203. package/src/screens/address/use-address-flow.ts +200 -0
  204. package/src/screens/address/use-label-pin.ts +80 -0
  205. package/src/screens/address/use-pin-actions.ts +192 -0
  206. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  207. package/src/screens/consent/model.ts +79 -10
  208. package/src/screens/liveness/CaptureRing.tsx +91 -0
  209. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  210. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  211. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  212. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  213. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  214. package/src/screens/liveness/index.ts +4 -0
  215. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  216. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  217. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  218. package/src/screens/useAddressPhotoAttach.ts +77 -0
  219. package/src/screens/useAwaitingPeople.ts +90 -0
  220. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  221. package/src/screens/usePoaAttach.ts +12 -15
  222. package/src/services/api-biometric.ts +37 -0
  223. package/src/services/api-types-biometric.ts +41 -0
  224. package/src/services/api-types.ts +251 -3
  225. package/src/services/api-verify-types.ts +90 -1
  226. package/src/services/api.ts +237 -3
  227. package/src/services/deviceMetadata.ts +1 -1
  228. package/src/services/errors.ts +10 -2
  229. package/src/services/location.ts +234 -0
  230. package/src/services/uploadErrors.ts +30 -0
  231. package/src/services/workflowGate.ts +8 -0
  232. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  233. package/src/store/address.ts +97 -0
  234. package/src/store/businessCheck.ts +113 -0
  235. package/src/store/derive.ts +44 -4
  236. package/src/store/kycStore.ts +352 -16
  237. package/src/store/serverConfig.ts +20 -0
  238. package/src/store/session.ts +227 -0
  239. package/src/store/state.ts +289 -2
  240. package/src/store/submit.ts +80 -13
  241. package/src/store/submitApplicant.ts +5 -11
  242. package/src/types/business.ts +23 -2
  243. package/src/types/config.ts +70 -2
  244. package/src/types/verification.ts +13 -1
  245. package/src/types/workflow.ts +112 -1
  246. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -0,0 +1,227 @@
1
+ import type { KycState, KycStore } from './state';
2
+ import { collectDeviceMetadata } from '../services/deviceMetadata';
3
+ import { restoreAddress } from './address';
4
+ import { getStepLog } from '../lib/step-log';
5
+ import { emptyKeyPerson, type KeyPersonEntry } from '../config/keyPeople';
6
+ import type { SessionStartResponse } from '../services/api-types';
7
+ import { persistentDeviceId } from '../services/fingerprint-sources';
8
+ import { openingStep } from './derive';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // The attempt SESSION: minting at launch, and progress writes as the user moves.
12
+ //
13
+ // Ported from the web SDK (MyazaKYC.tsx beginSession + useSessionProgress),
14
+ // with the same contracts:
15
+ //
16
+ // Best-effort throughout. Sessions power resumability, the dashboard's live
17
+ // attempt view, session webhooks, and the registry check at selection —
18
+ // verifying is never conditional on one existing, so every failure here is
19
+ // swallowed.
20
+ //
21
+ // Untouched progress is never written. The presence of stored progress IS
22
+ // "they started"; a save-on-mount would make every opened flow look started,
23
+ // and the server tells an abandoned first screen from a worked-through form
24
+ // by exactly this absence.
25
+ //
26
+ // A remount RESTORES the user to where they were, like web: the resumed
27
+ // session's stored progress hydrates the store (restoreAttemptProgress),
28
+ // so their step, captures and typed data survive an app restart.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const SAVE_DEBOUNCE_MS = 800;
32
+
33
+ // Collapses concurrent mints into one request per launch (mirrors the web
34
+ // SDK's start-session-once). Dev-mode double-invocation fired /session/start
35
+ // twice ~600ms apart; without an externalUserId the server has nothing to
36
+ // resume by, so the second call minted a SECOND session — the flow adopted one
37
+ // and the orphan sat on the org's list forever as "Not started". Short window
38
+ // on purpose: it collapses a double-invoke, never caches sessions, and a
39
+ // failure clears immediately so a retry is a real retry.
40
+ const inflightStarts = new Map<string, Promise<SessionStartResponse>>();
41
+
42
+ /** Mint (or resume) the attempt session and remember its id. Fire-and-forget. */
43
+ export function startAttemptSession(store: KycStore): void {
44
+ const s = store.getState();
45
+ if (s.sessionId) return;
46
+ const externalUserId = s.config.userId ?? s.config.metadata?.['userId'];
47
+ const key = `${s.config.apiKey}|${s.config.workflowId ?? ''}|${externalUserId ?? ''}`;
48
+ let start = inflightStarts.get(key);
49
+ if (!start) {
50
+ // The device id is the anonymous-mount resume fallback: without a userId
51
+ // the server has nothing else to find the previous attempt by, and every
52
+ // app relaunch minted a fresh session. Hashed server-side before storage.
53
+ start = persistentDeviceId().then((deviceRef) =>
54
+ s.api.startSession({
55
+ externalUserId,
56
+ ...(deviceRef ? { deviceRef } : {}),
57
+ ...(s.config.workflowId ? { workflowId: s.config.workflowId } : {}),
58
+ // What this phone is, sent up front: the dashboard's in-progress row
59
+ // shows Device and Source from the moment the SDK loads.
60
+ device: collectDeviceMetadata() as unknown as Record<string, unknown>,
61
+ }),
62
+ );
63
+ inflightStarts.set(key, start);
64
+ start.then(
65
+ () => setTimeout(() => inflightStarts.delete(key), 2000),
66
+ () => inflightStarts.delete(key),
67
+ );
68
+ }
69
+ void start
70
+ .then((res) => {
71
+ store.getState().setSessionId(res.sessionId, res.url ?? null);
72
+ // Resuming: put the user back where they were, exactly as web does.
73
+ // Media references are pruned server-side of anything expired, so a
74
+ // restored capture slot is one whose bytes genuinely still exist.
75
+ if (res.progress) restoreAttemptProgress(store, res.progress);
76
+ })
77
+ .catch(() => undefined);
78
+ }
79
+
80
+ /**
81
+ * Hydrate the store from a resumed session's stored progress — the RN mirror
82
+ * of the web reducer's RESTORE_PROGRESS. Only fields the snapshot carries are
83
+ * touched; everything else keeps its launch value, so a partial or old
84
+ * snapshot degrades to restoring less, never to breaking the flow.
85
+ */
86
+ export function restoreAttemptProgress(
87
+ store: KycStore,
88
+ progress: NonNullable<SessionStartResponse['progress']>,
89
+ ): void {
90
+ const s = store.getState();
91
+ const d = (progress.data ?? {}) as Record<string, unknown>;
92
+
93
+ const app = d['businessApplication'] as
94
+ | (Partial<KycState['businessApplication']> & { keyPeople?: Array<Record<string, unknown>> })
95
+ | undefined;
96
+
97
+ store.setState({
98
+ // The terminal step is a RESULT, not a position. A submission that FAILED
99
+ // still writes `submitted` as the last step reached, so resuming there
100
+ // submits again, fails the same way and writes it again: closing the app
101
+ // and reopening lands straight back on the error with no way forward.
102
+ // Starting the flow again also re-resolves the workflow, so an applicant
103
+ // caught mid-flight across a republish walks the CURRENT steps rather than
104
+ // submitting against rules they were never shown.
105
+ ...(typeof progress.step === 'string' && progress.step !== 'submitted'
106
+ ? { currentStep: progress.step as KycState['currentStep'] }
107
+ : {}),
108
+ ...(progress.mediaIds ? { mediaIds: { ...s.mediaIds, ...progress.mediaIds } } : {}),
109
+ ...(typeof d['selectedCountry'] === 'string'
110
+ ? { selectedCountry: d['selectedCountry'] as string }
111
+ : {}),
112
+ ...(typeof d['selectedIdType'] === 'string'
113
+ ? { selectedIdType: d['selectedIdType'] as KycState['selectedIdType'] }
114
+ : {}),
115
+ ...(typeof d['idNumber'] === 'string' ? { idNumber: d['idNumber'] as string } : {}),
116
+ ...(d['business'] && typeof d['business'] === 'object'
117
+ ? { business: { ...s.business, ...(d['business'] as object) } }
118
+ : {}),
119
+ ...(app
120
+ ? {
121
+ businessApplication: {
122
+ ...s.businessApplication,
123
+ ...app,
124
+ // Rows saved BEFORE the sectioned redesign predate `roles`, `title`
125
+ // and `owners`; a restored attempt must not hand the cards a shape
126
+ // they cannot read. Same normalization as web's RESTORE_PROGRESS.
127
+ ...(app.keyPeople
128
+ ? {
129
+ keyPeople: app.keyPeople.map((row) => ({
130
+ ...emptyKeyPerson(),
131
+ ...row,
132
+ roles:
133
+ Array.isArray(row['roles']) && (row['roles'] as unknown[]).length > 0
134
+ ? (row['roles'] as KeyPersonEntry['roles'])
135
+ : [(row['role'] as KeyPersonEntry['role']) ?? 'director'],
136
+ title: typeof row['title'] === 'string' ? (row['title'] as string) : '',
137
+ owners: Array.isArray(row['owners'])
138
+ ? (row['owners'] as KeyPersonEntry['owners'])
139
+ : [],
140
+ })),
141
+ }
142
+ : {}),
143
+ } as KycState['businessApplication'],
144
+ }
145
+ : {}),
146
+ ...(d['contact'] && typeof d['contact'] === 'object'
147
+ ? { contact: { ...s.contact, ...(d['contact'] as object) } }
148
+ : {}),
149
+ // The pin restores only when it is a real pin — a partial snapshot must
150
+ // degrade to restoring less, never to a shape the step cannot read.
151
+ ...((() => {
152
+ const a = d['address'] as Record<string, unknown> | undefined;
153
+ if (!a || typeof a['lat'] !== 'number' || typeof a['lng'] !== 'number') return {};
154
+ return { address: restoreAddress(a) };
155
+ })()),
156
+ ...(d['questionnaireAnswers'] && typeof d['questionnaireAnswers'] === 'object'
157
+ ? {
158
+ questionnaireAnswers: {
159
+ ...s.questionnaireAnswers,
160
+ ...(d['questionnaireAnswers'] as object),
161
+ },
162
+ }
163
+ : {}),
164
+ });
165
+ }
166
+
167
+ /** The progress snapshot the server stores — mirrors the web SDK's shape, so
168
+ * the dashboard's attempt page reads both without branching. */
169
+ export function progressFromState(s: ReturnType<KycStore['getState']>): Record<string, unknown> {
170
+ const mediaIds = Object.fromEntries(
171
+ Object.entries(s.mediaIds ?? {}).filter(([, v]) => typeof v === 'string' && v),
172
+ );
173
+ return {
174
+ step: s.currentStep,
175
+ stepLog: getStepLog(),
176
+ mediaIds,
177
+ data: {
178
+ selectedCountry: s.selectedCountry ?? undefined,
179
+ selectedIdType: s.selectedIdType ?? undefined,
180
+ idNumber: s.idNumber || undefined,
181
+ business: s.business,
182
+ businessApplication: s.businessApplication,
183
+ contact: s.contact,
184
+ questionnaireAnswers: s.questionnaireAnswers,
185
+ address: s.address ?? undefined,
186
+ },
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Nothing here needs saving until they move off the opening screen. Judged
192
+ * against the flow's OPENING step ('consent' unless the workflow switched it
193
+ * off, then the first real step), or a consent-less flow would save on mount
194
+ * and every opened link would read "In progress" on the dashboard.
195
+ */
196
+ export function isUntouchedProgress(payload: Record<string, unknown>, opening: string = 'consent'): boolean {
197
+ if (payload['step'] !== opening) return false;
198
+ return Object.keys((payload['mediaIds'] as object) ?? {}).length === 0;
199
+ }
200
+
201
+ /**
202
+ * Persist progress as the user advances. Debounced, deduped by snapshot, and
203
+ * a no-op until the session exists.
204
+ */
205
+ export function watchSessionProgress(store: KycStore): () => void {
206
+ let timer: ReturnType<typeof setTimeout> | null = null;
207
+ let lastSaved = '';
208
+
209
+ const unsubscribe = store.subscribe(() => {
210
+ if (timer) clearTimeout(timer);
211
+ timer = setTimeout(() => {
212
+ const s = store.getState();
213
+ if (!s.sessionId) return;
214
+ const payload = progressFromState(s);
215
+ if (isUntouchedProgress(payload, openingStep(s))) return;
216
+ const fingerprint = JSON.stringify(payload);
217
+ if (fingerprint === lastSaved) return;
218
+ lastSaved = fingerprint;
219
+ void s.api.saveProgress(s.sessionId, payload).catch(() => undefined);
220
+ }, SAVE_DEBOUNCE_MS);
221
+ });
222
+
223
+ return () => {
224
+ unsubscribe();
225
+ if (timer) clearTimeout(timer);
226
+ };
227
+ }
@@ -9,7 +9,7 @@ import type { FlashHole } from '../components/flashHoleGeometry';
9
9
 
10
10
  import type { StoreApi } from 'zustand/vanilla';
11
11
 
12
- import type { KYCApi, KeyPersonInvite } from '../services/api';
12
+ import type { BusinessCompanyRecord, KYCApi, KeyPersonInvite, RegistryOfficer } from '../services/api';
13
13
  import type { IdType, KYCStep, ResolvedKYCConfig } from '../types/config';
14
14
  import type { PoaDocumentType } from '../types/workflow';
15
15
  import type { ApplicantRole } from '../types/business';
@@ -21,10 +21,13 @@ import type { ServerConfigState } from './serverConfig';
21
21
  import type { CaptureIntegrity } from '../liveness/integritySignals';
22
22
  import type { MrzScan } from '../mrz/parse';
23
23
  import type { EmrtdReadResult } from '../emrtd';
24
+ import type { SelfieUploadState } from '../lib/selfie-upload-wait';
24
25
 
25
26
  export interface KYCMediaIds {
26
27
  /** Proof-of-address document (image or PDF). */
27
28
  proofOfAddress?: string;
29
+ /** Address Intelligence door photo (image only). */
30
+ addressPhoto?: string;
28
31
  documentFront?: string;
29
32
  documentBack?: string;
30
33
  selfie?: string;
@@ -35,7 +38,7 @@ export interface KYCMediaIds {
35
38
 
36
39
  export interface KYCSubmissionResult {
37
40
  verificationId: string;
38
- status: 'pending';
41
+ status: 'processing';
39
42
  }
40
43
 
41
44
  export type DocumentScanPhase = 'front' | 'back' | 'complete';
@@ -48,12 +51,69 @@ export interface BusinessState {
48
51
  product: string | null;
49
52
  registrationNumber: string;
50
53
  registrationName: string;
54
+ /** Dev/sandbox only: pins the canned outcome served instead of calling the
55
+ * register. Sent as metadata.sandboxOutcome; production ignores it. */
56
+ sandboxOutcome: string;
57
+ /** ISO 3166-2 registry region, for the four countries whose register is
58
+ * split by state or emirate. Empty for everywhere else. Rides the search
59
+ * and the selection-time check; the submission relies on the prepaid
60
+ * record, mirroring the web SDK. */
61
+ subdivisionCode: string;
51
62
  /** Where key-people invite links are emailed. */
52
63
  contactEmail: string;
53
64
  address: string;
54
65
  email: string;
55
66
  phone: string;
56
67
  website: string;
68
+ /** Registry facts the applicant states; submitted as their own answer. */
69
+ dateOfIncorporation: string;
70
+ taxId: string;
71
+ vatNumber: string;
72
+ companyType: string;
73
+ natureOfBusiness: string;
74
+ }
75
+
76
+ /**
77
+ * The registry check run when the applicant confirms their company.
78
+ *
79
+ * `skipped` and `limit_reached` are normal outcomes, not failures: the
80
+ * organisation could not be charged (or this application has spent its lookup
81
+ * budget), so the flow carries on and the check happens at submission instead.
82
+ * Mirrors the web SDK's BusinessCheckState — keep the two in lockstep.
83
+ */
84
+ export interface BusinessCheckState {
85
+ status: 'idle' | 'checking' | 'found' | 'not_found' | 'skipped' | 'unavailable' | 'limit_reached';
86
+ /** What the register holds, when it answered. */
87
+ company: BusinessCompanyRecord | null;
88
+ /** The officers on file — what makes the key-people question a confirmation. */
89
+ officers: RegistryOfficer[];
90
+ /** Which company was checked (normalised), so a changed number re-runs it. */
91
+ checkedNumber: string | null;
92
+ /**
93
+ * Which form fields the REGISTER filled, as opposed to the applicant.
94
+ *
95
+ * Kept so that changing which company this is can clear exactly those and
96
+ * nothing else. Without it, switching company left the previous register's
97
+ * address and email sitting in the form under the new company's name — and
98
+ * because the prefill only writes into empty fields, those leftovers also
99
+ * blocked the new register's real values from ever landing.
100
+ */
101
+ prefilled: (keyof BusinessState)[];
102
+ }
103
+
104
+ export const EMPTY_BUSINESS_CHECK: BusinessCheckState = {
105
+ status: 'idle',
106
+ company: null,
107
+ officers: [],
108
+ checkedNumber: null,
109
+ prefilled: [],
110
+ };
111
+
112
+ /** What `checkBusiness` resolves with. Only a definitive "not on the register"
113
+ * stops the flow; the company record is handed back for the prefill. */
114
+ export interface BusinessCheckResult {
115
+ canContinue: boolean;
116
+ company: BusinessCompanyRecord | null;
57
117
  }
58
118
 
59
119
  /** One uploaded supporting document. */
@@ -83,6 +143,13 @@ export interface BusinessApplicationState {
83
143
  * one screening, no duplicate invite.
84
144
  */
85
145
  applicantKeyPersonIndex: number | null;
146
+ /**
147
+ * The applicant attests that no natural person qualifies as a UBO (public
148
+ * share structures, complex trusts, nominee arrangements) - the FATF
149
+ * fallback. An attestation the server records and the org can branch on,
150
+ * never a verdict; the registry lookup still says what it says.
151
+ */
152
+ uboUnidentifiable: boolean;
86
153
  }
87
154
 
88
155
  export const EMPTY_BUSINESS_APPLICATION: BusinessApplicationState = {
@@ -91,6 +158,7 @@ export const EMPTY_BUSINESS_APPLICATION: BusinessApplicationState = {
91
158
  applicantRole: null,
92
159
  applicantName: '',
93
160
  applicantKeyPersonIndex: null,
161
+ uboUnidentifiable: false,
94
162
  };
95
163
 
96
164
  export const EMPTY_BUSINESS: BusinessState = {
@@ -98,11 +166,18 @@ export const EMPTY_BUSINESS: BusinessState = {
98
166
  product: null,
99
167
  registrationNumber: '',
100
168
  registrationName: '',
169
+ sandboxOutcome: '',
170
+ subdivisionCode: '',
101
171
  contactEmail: '',
102
172
  address: '',
103
173
  email: '',
104
174
  phone: '',
105
175
  website: '',
176
+ dateOfIncorporation: '',
177
+ taxId: '',
178
+ vatNumber: '',
179
+ companyType: '',
180
+ natureOfBusiness: '',
106
181
  };
107
182
 
108
183
  export interface ContactState {
@@ -112,6 +187,80 @@ export interface ContactState {
112
187
  emailToken?: string;
113
188
  /** Single-use proof from a passed phone check. */
114
189
  phoneToken?: string;
190
+ /**
191
+ * Channels whose proof the SERVER refused at submit (422
192
+ * contact_verification_required). Proofs are single-use and expire ~30
193
+ * minutes after the OTP check, but they ride session progress and are
194
+ * restored on resume — so a resumed attempt can carry a dead proof while
195
+ * the step still shows "verified". This routes the person back to
196
+ * re-verify instead of a retry that resubmits the same dead token forever;
197
+ * setContactVerified clears its channel.
198
+ */
199
+ expired?: Array<'email' | 'phone'>;
200
+ }
201
+
202
+ /**
203
+ * The smart address the address-collection step gathers. The pin is the map
204
+ * centre the user settled on; the device* fields are the one-shot attest fix
205
+ * taken at Continue (a CLAIM the server evaluates, never a verdict).
206
+ */
207
+ export interface AddressState {
208
+ lat: number;
209
+ lng: number;
210
+ accuracy: number | null;
211
+ directions: string;
212
+ /** Building or estate name — back on the edit-details form (user decision
213
+ * 2026-08-31: everything editable, OkHi-style). */
214
+ propertyName: string;
215
+ propertyNumber: string;
216
+ /** A street the applicant TYPED. Prefilled from the map's answer in the
217
+ * edit-details sheet; stored only once the applicant edits it, so an
218
+ * untouched prefill is never submitted as their claim. */
219
+ street?: string;
220
+ /** The rest of the OkHi-style edit-details form: unit + area/region
221
+ * corrections. All applicant claims, stored only when typed; the server
222
+ * never feeds them into corroboration. undefined = never touched (the
223
+ * map's prefill shows), '' = cleared. */
224
+ unit?: string;
225
+ neighbourhood?: string;
226
+ city?: string;
227
+ state?: string;
228
+ postcode?: string;
229
+ /**
230
+ * The pin's human-readable line, from a search pick or a reverse geocode.
231
+ * Shown in the flow AND sent with the submission as the applicant-confirmed
232
+ * line: the server prefers it over its own weaker derivation.
233
+ */
234
+ label?: string;
235
+ /** Where the label was PICKED for. Presence means the label is
236
+ * human-confirmed, so it survives pin nudges instead of being re-derived on
237
+ * every drag. Absent = the label came from a reverse geocode. Display
238
+ * bookkeeping — never on the wire. */
239
+ pickedAt?: { lat: number; lng: number };
240
+ /** The applicant explicitly chose to KEEP the picked label after moving the
241
+ * pin. Reset when the pin crosses the credibility radius, so the question
242
+ * is asked again exactly once out there. Never on the wire. */
243
+ labelKept?: boolean;
244
+ /** The label broken down — what the details sheet shows as structured rows.
245
+ * Display only, like label's own anchor fields. */
246
+ parts?: {
247
+ street?: string | null;
248
+ area?: string | null;
249
+ city?: string | null;
250
+ state?: string | null;
251
+ postcode?: string | null;
252
+ /** The pin's own ISO-2, from the geocoder; never a sheet row. */
253
+ country?: string | null;
254
+ };
255
+ /** Street View entrance frame: coordinates only, so the server fetches the
256
+ * image with its own key. Captured here through the framed
257
+ * /embed/street-view page in a WebView (FramedStreetView), and also
258
+ * restored from a session begun on a hosted page. */
259
+ streetView?: { panoId: string; heading: number; pitch: number; fov: number };
260
+ deviceLat?: number;
261
+ deviceLng?: number;
262
+ deviceAccuracy?: number;
263
+ capturedAt?: string;
115
264
  }
116
265
 
117
266
  /** Document-capture sub-phase — drives the sheet header title/description. */
@@ -120,18 +269,93 @@ export type DocumentCapturePhase = 'front' | 'front-preview' | 'back' | 'review'
120
269
  /** The mediaIds keys settable via `setMediaId`. */
121
270
  export type MediaIdKey = keyof KYCMediaIds;
122
271
 
272
+ /** One committed multi-ID check. `documentFront/Back` are mediaIds; the
273
+ * `*Image` fields are LOCAL previews and never reach the wire. */
274
+ export interface MultiIdSlot {
275
+ idType: IdType;
276
+ idNumber?: string;
277
+ documentFront?: string;
278
+ documentBack?: string;
279
+ /** Each check records its OWN document capture. The row's flat
280
+ * documentFrontVideo column holds one, so a multi-ID run keeps them per
281
+ * check or loses all but one. */
282
+ documentFrontVideo?: string;
283
+ documentBackVideo?: string;
284
+ /** This check's own chip read. The chip belongs to a PARTICULAR document, so
285
+ * sending it top-level attributed it to the primary check — which is how a
286
+ * passport's chip read was dropped for being submitted alongside a BVN. */
287
+ chipData?: EmrtdReadResult | null;
288
+ documentFrontImage?: string;
289
+ documentBackImage?: string;
290
+ }
291
+
123
292
  export interface KycState {
124
293
  config: ResolvedKYCConfig;
125
294
  api: KYCApi;
126
295
 
127
296
  currentStep: KYCStep;
297
+ /**
298
+ * The attempt SESSION this run is recorded under (`/session/start`). Null
299
+ * when minting failed or preview — verifying is never conditional on it. It
300
+ * rides the /verify body so the verification adopts the session's id, and it
301
+ * is what the registry check at selection anchors its charge on.
302
+ */
303
+ sessionId: string | null;
304
+ /** The session's own hosted web page (see SessionStartResponse.url). */
305
+ sessionUrl: string | null;
306
+ /**
307
+ * What the register said about the company the applicant identified — the
308
+ * paid check run at SELECTION (`/business/select`), so the officer list is
309
+ * already here by the time the key-people step asks for it. `officers` is
310
+ * the prefill's input; `checkedNumber` stops a re-check of the same company.
311
+ */
312
+ businessCheck: BusinessCheckState;
128
313
  /**
129
314
  * The country picked on the multi-region country-select step. Null on a
130
315
  * single-country flow, where `config.country` is the answer.
131
316
  */
132
317
  selectedCountry: string | null;
318
+ /**
319
+ * The declared country was GUESSED (the address scope's IP default, or a
320
+ * geocode adopted from the applicant's fix) rather than picked, so later
321
+ * evidence may correct it; an explicit pick clears it. Mirrors the web
322
+ * SDK's `countryAutoPicked`. See lib/country-adoption.ts.
323
+ */
324
+ countryAutoPicked: boolean;
133
325
  selectedIdType: IdType | null;
134
326
  idNumber: string | null;
327
+ /**
328
+ * Multi-ID: which check the applicant is on (0-based), and the ones already
329
+ * committed. A committed slot keeps its LOCAL preview images as well as its
330
+ * mediaIds, so stepping back into it restores what was captured rather than
331
+ * asking for a document that is still perfectly good.
332
+ */
333
+ multiIdSlotIndex: number;
334
+ multiIdSlots: MultiIdSlot[];
335
+ /**
336
+ * Capture previews handed back when stepping BACK into a committed slot.
337
+ *
338
+ * RN keeps document previews in the capture screen's own state, so a restored
339
+ * slot has to hand them somewhere the remounting screen can read them. Null
340
+ * on the forward journey.
341
+ */
342
+ multiIdRestored: { front?: string; back?: string } | null;
343
+ /**
344
+ * The captured selfie's local file path, kept CENTRALLY (the liveness
345
+ * screen's own state dies with it on unmount) so leaving the step and
346
+ * returning restores the review screen instead of re-running the whole
347
+ * gesture check. mediaIds.selfie beside it is the durable record; on a
348
+ * restored session the path may be gone while the mediaId survives.
349
+ */
350
+ selfiePreviewUri: string | null;
351
+ /**
352
+ * Where the selfie (and its liveness video) upload has got to. The liveness
353
+ * step kicks the upload off and, with the review hidden, hands over BEFORE
354
+ * it lands; the submitted step waits on this record rather than on the
355
+ * step that started it (lib/selfie-upload-wait.ts). mediaIds.selfie beside
356
+ * it is the durable proof; this is the in-flight state.
357
+ */
358
+ selfieUpload: SelfieUploadState;
135
359
  mediaIds: KYCMediaIds;
136
360
  submissionResult: KYCSubmissionResult | null;
137
361
  serverConfig: ServerConfigState;
@@ -203,6 +427,26 @@ export interface KycState {
203
427
  poaDocumentType: PoaDocumentType | null;
204
428
  /** Its file name, so the uploaded state can name what it has. */
205
429
  poaFileName: string | null;
430
+ /** The smart address, when the address-collection step gathered one. */
431
+ address: AddressState | null;
432
+ /** Local URI of the uploaded entrance photo, so the review step can show it.
433
+ * A display artefact — never serialised, never restored (a resumed session
434
+ * holds the mediaId but not the bytes). */
435
+ addressPhotoPreview: string | null;
436
+ /** The presence "how it works" primer was acknowledged this session. */
437
+ addressIntroSeen: boolean;
438
+ /** The entrance step is showing the Street View framer, so the sheet
439
+ * header says so. Transient: never serialised, never restored. */
440
+ addressEntranceFraming: boolean;
441
+ /** Dev/sandbox only: the pinned address RESULT outcome (the web SDK's
442
+ * Test-result tabs). Null = the server default. Rides
443
+ * metadata.sandboxOutcome at submit; production ignores it. */
444
+ addressSandboxOutcome:
445
+ | 'address_attested'
446
+ | 'address_corroborated'
447
+ | 'address_collected'
448
+ | 'address_mismatch'
449
+ | null;
206
450
  /**
207
451
  * Contact-verification results. The proofs are single-use and ride the
208
452
  * /verify submission; the addresses are kept so returning to the step (or a
@@ -217,15 +461,43 @@ export interface KycState {
217
461
  // Actions
218
462
  loadServerConfig: () => Promise<void>;
219
463
  setCountry: (country: string) => void;
464
+ /** Declare a GUESSED country: the same reset as `setCountry`, flagged so
465
+ * later evidence may correct it. Mirrors the web SDK's SET_COUNTRY_AUTO. */
466
+ setCountryAuto: (country: string) => void;
220
467
  setIdType: (idType: IdType) => void;
221
468
  setIdNumber: (idNumber: string) => void;
469
+ /** Commit the current slot's evidence and move to the next check. The
470
+ * previews come from the capture screen, which owns them. */
471
+ commitMultiIdSlot: (nextStep: KYCStep, previews?: { front?: string; back?: string }) => void;
472
+ /** Step BACK into the previous slot, restoring what it captured. */
473
+ uncommitMultiIdSlot: (step: KYCStep) => void;
222
474
  setMediaId: (key: MediaIdKey, mediaId: string) => void;
475
+ /** Record the captured selfie's local path (null on retake). */
476
+ setSelfiePreview: (uri: string | null) => void;
477
+ /** Retake: drop the selfie preview + its uploaded media ids in one set. */
478
+ clearSelfie: () => void;
479
+ /** The upload hook's progress report (see `selfieUpload`). */
480
+ setSelfieUpload: (upload: SelfieUploadState) => void;
223
481
  setDocumentMediaId: (mediaId: string, side: 'front' | 'back') => void;
224
482
  setQuestionnaireAnswer: (key: string, value: QuestionnaireAnswerValue | undefined) => void;
225
483
  setContactVerified: (channel: 'email' | 'phone', destination: string, token: string) => void;
226
484
  setContactDestination: (channel: 'email' | 'phone', destination: string) => void;
485
+ /** The server refused these channels' proofs at submit — drop the tokens and
486
+ * flag the channels so their steps re-verify then resubmit. */
487
+ clearContactProofs: (channels: Array<'email' | 'phone'>) => void;
227
488
  setBusinessField: <K extends keyof BusinessState>(key: K, value: BusinessState[K]) => void;
489
+ /** Writes the register's answers into empty fields + records which ones it
490
+ * filled, in one set — so a company change can clear exactly those. */
491
+ applyBusinessPrefill: (
492
+ patch: Partial<BusinessState>,
493
+ prefilled: (keyof BusinessState)[],
494
+ ) => void;
495
+ setSessionId: (sessionId: string, sessionUrl?: string | null) => void;
496
+ /** Run the paid registry check for the typed company. Never blocks the flow. */
497
+ checkBusiness: () => Promise<BusinessCheckResult>;
228
498
  setKeyPeople: (rows: KeyPersonEntry[]) => void;
499
+ /** The UBO-exemption attestation (FATF fallback) — see BusinessApplicationState. */
500
+ setUboUnidentifiable: (checked: boolean) => void;
229
501
  setBusinessDocument: (doc: BusinessDocumentUpload) => void;
230
502
  removeBusinessDocument: (type: string) => void;
231
503
  setApplicant: (role: ApplicantRole, name: string, keyPersonIndex?: number | null) => void;
@@ -234,6 +506,21 @@ export interface KycState {
234
506
  setChipData: (data: EmrtdReadResult) => void;
235
507
  setProofOfAddress: (mediaId: string, docType: PoaDocumentType, fileName: string) => void;
236
508
  clearProofOfAddress: () => void;
509
+ /** Set / update the smart address (null clears it). */
510
+ setAddress: (address: AddressState | null) => void;
511
+ setAddressSandboxOutcome: (outcome: KycState['addressSandboxOutcome']) => void;
512
+ /** Attach / remove the door photo (null removes it). */
513
+ setAddressPhoto: (mediaId: string | null) => void;
514
+ /** Remember the local URI of the picked entrance photo (null on remove). */
515
+ setAddressPhotoPreview: (uri: string | null) => void;
516
+ /** The applicant acknowledged the presence primer. */
517
+ markAddressIntroSeen: () => void;
518
+ /** The entrance step is (or stops) showing the Street View framer. */
519
+ setAddressEntranceFraming: (framing: boolean) => void;
520
+ /** Drop the pin, its uploaded photo and that photo's preview together — a
521
+ * half-cleared address would show an entrance for a building nobody
522
+ * pinned. */
523
+ clearAddress: () => void;
237
524
  setDocumentCapturePhase: (phase: DocumentCapturePhase) => void;
238
525
  setContactChallenge: (challenge: ContactChallenge | null) => void;
239
526
  setImmersiveCapture: (immersive: boolean) => void;