@myazahq/kyc-sdk-react-native 2.4.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 (117) 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 -1
  16. package/src/MyazaKYC.tsx +12 -0
  17. package/src/components/DashedBorder.tsx +55 -0
  18. package/src/components/DialCodePicker.tsx +14 -7
  19. package/src/components/Icon.tsx +9 -0
  20. package/src/components/MultiIdProgress.tsx +107 -0
  21. package/src/components/MyazaInput.tsx +23 -4
  22. package/src/components/MyazaSelect.tsx +2 -1
  23. package/src/components/OwnershipSlider.tsx +102 -0
  24. package/src/components/PhoneNumberInput.tsx +37 -4
  25. package/src/components/StaggerIn.tsx +63 -0
  26. package/src/components/StepView.tsx +26 -0
  27. package/src/components/glass/FloatingSheet.tsx +73 -21
  28. package/src/components/glass/GlassSheet.tsx +14 -2
  29. package/src/components/stepHeaderMeta.tsx +3 -1
  30. package/src/config/business.ts +6 -6
  31. package/src/config/businessDetailsValidity.ts +52 -0
  32. package/src/config/businessPrefill.ts +72 -0
  33. package/src/config/businessSteps.ts +14 -3
  34. package/src/config/keyPeople.ts +111 -7
  35. package/src/config/keyPeopleCorporate.ts +19 -0
  36. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  37. package/src/config/keyPeopleOwners.ts +26 -0
  38. package/src/config/keyPeoplePrefill.ts +138 -0
  39. package/src/config/keyPeopleSectionDefs.ts +65 -0
  40. package/src/config/keyPeopleSections.ts +167 -0
  41. package/src/config/registrationHint.ts +1 -1
  42. package/src/config/stepOrder.ts +24 -4
  43. package/src/config/theme.ts +8 -2
  44. package/src/config/website.ts +36 -0
  45. package/src/config/workflowMerge.ts +5 -0
  46. package/src/emrtd/activeAuth.ts +100 -0
  47. package/src/emrtd/extras.ts +76 -0
  48. package/src/emrtd/files.ts +5 -0
  49. package/src/emrtd/index.ts +1 -0
  50. package/src/emrtd/read.ts +8 -1
  51. package/src/emrtd/session.ts +41 -0
  52. package/src/emrtd/stages.ts +11 -0
  53. package/src/lib/contact-recovery.ts +44 -0
  54. package/src/lib/multi-id.ts +210 -0
  55. package/src/lib/resubmit.ts +130 -0
  56. package/src/lib/screen-corners.ts +50 -0
  57. package/src/lib/step-log.ts +24 -3
  58. package/src/lib/use-multi-id-plan.ts +29 -0
  59. package/src/liveness/useModelReady.ts +65 -0
  60. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  61. package/src/screens/ApplicantRoleStep.tsx +9 -5
  62. package/src/screens/BusinessCheckPanel.tsx +89 -0
  63. package/src/screens/BusinessDetailsFields.tsx +109 -0
  64. package/src/screens/BusinessDetailsStep.tsx +131 -145
  65. package/src/screens/BusinessDocumentSlot.tsx +2 -3
  66. package/src/screens/BusinessDocumentsStep.tsx +1 -1
  67. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  68. package/src/screens/BusinessPickedCard.tsx +86 -0
  69. package/src/screens/BusinessPickedSection.tsx +51 -0
  70. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  71. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  72. package/src/screens/BusinessSearch.tsx +199 -0
  73. package/src/screens/BusinessSearchResults.tsx +115 -0
  74. package/src/screens/CompanyInfoFields.tsx +86 -51
  75. package/src/screens/ConsentStep.tsx +31 -0
  76. package/src/screens/ContactDestinationField.tsx +1 -0
  77. package/src/screens/ContactVerificationStep.tsx +34 -3
  78. package/src/screens/IdTypeStep.tsx +12 -5
  79. package/src/screens/KeepLinksSheet.tsx +88 -0
  80. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  81. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  82. package/src/screens/KeyPeoplePending.tsx +154 -0
  83. package/src/screens/KeyPeopleSection.tsx +162 -0
  84. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  85. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  86. package/src/screens/KeyPersonCard.tsx +36 -4
  87. package/src/screens/KeyPersonForm.tsx +195 -36
  88. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  89. package/src/screens/KeyPersonOwners.tsx +109 -0
  90. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  91. package/src/screens/KeyPersonSheet.tsx +92 -108
  92. package/src/screens/LivenessStep.tsx +18 -0
  93. package/src/screens/NfcStep.tsx +14 -0
  94. package/src/screens/ProofOfAddressStep.tsx +6 -3
  95. package/src/screens/SubmittedStep.tsx +66 -10
  96. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  97. package/src/screens/useAwaitingPeople.ts +90 -0
  98. package/src/services/api-types.ts +158 -2
  99. package/src/services/api-verify-types.ts +53 -1
  100. package/src/services/api.ts +104 -0
  101. package/src/services/deviceMetadata.ts +1 -1
  102. package/src/services/errors.ts +10 -2
  103. package/src/services/workflowGate.ts +1 -0
  104. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  105. package/src/store/businessCheck.ts +113 -0
  106. package/src/store/derive.ts +15 -0
  107. package/src/store/kycStore.ts +241 -14
  108. package/src/store/serverConfig.ts +2 -0
  109. package/src/store/session.ts +208 -0
  110. package/src/store/state.ts +155 -2
  111. package/src/store/submit.ts +63 -13
  112. package/src/store/submitApplicant.ts +5 -11
  113. package/src/types/business.ts +23 -2
  114. package/src/types/config.ts +22 -1
  115. package/src/types/verification.ts +1 -1
  116. package/src/types/workflow.ts +15 -0
  117. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -47,9 +47,32 @@ export interface KeyPersonInvite {
47
47
  inviteUrl: string;
48
48
  }
49
49
 
50
+ /**
51
+ * The one status vocabulary, shared by `GET /api/kyc/status/:id`, the
52
+ * secret-key result route and every verification webhook.
53
+ *
54
+ * `status` is what happened; `checkStatus` beside it is what the CHECKS found.
55
+ * They differ when a person overrode the automated result, which is the case
56
+ * worth being able to see: `approved` with `checkStatus: 'failed'` means
57
+ * somebody accepted the applicant despite a failed check, and the reason says
58
+ * what they accepted them despite.
59
+ */
60
+ export type SessionStatus =
61
+ | 'not_started'
62
+ | 'in_progress'
63
+ | 'processing'
64
+ | 'in_review'
65
+ | 'awaiting_resubmission'
66
+ | 'approved'
67
+ | 'declined'
68
+ | 'abandoned'
69
+ | 'expired'
70
+ | 'error';
71
+
50
72
  export interface VerifyResponse {
51
73
  verificationId: string;
52
- status: 'pending';
74
+ /** Always `processing`: accepted, checks running. */
75
+ status: 'processing';
53
76
  /**
54
77
  * KYB with applicant verification: the KeyPerson row the applicant's OWN
55
78
  * identity check must link back to, via `metadata.userId` on a second,
@@ -66,7 +89,9 @@ export interface VerifyResponse {
66
89
  */
67
90
  export interface VerificationStatusResponse {
68
91
  verificationId: string;
69
- status: 'pending' | 'verified' | 'failed' | 'not_found' | 'error';
92
+ status: SessionStatus;
93
+ /** What the CHECKS found, unchanged by any later decision. */
94
+ checkStatus?: 'pending' | 'verified' | 'failed' | 'not_found' | 'error';
70
95
  reason?: string | null;
71
96
  reasonCode?: string | null;
72
97
  createdAt: string;
@@ -109,6 +134,14 @@ export interface SdkConfigResponse {
109
134
  environment: 'DEVELOPMENT' | 'SANDBOX' | 'PRODUCTION';
110
135
  idTypes: SdkConfigIdType[];
111
136
  branding?: SdkConfigBranding;
137
+ /**
138
+ * The visitor's country, resolved from their IP.
139
+ *
140
+ * A GUESS and only ever a DEFAULT — nothing branches on it and it never
141
+ * reaches a verification. Deliberately not evidence: device intelligence
142
+ * carries the same lookup as a RISK signal, and the two must not be confused.
143
+ */
144
+ geoCountry?: string | null;
112
145
  }
113
146
 
114
147
  /**
@@ -141,6 +174,14 @@ export interface WorkflowResolutionResponse {
141
174
  /** Org allowlist + per-ID feature flags (same shape as /config). */
142
175
  idTypes: SdkConfigIdType[];
143
176
  branding?: SdkConfigBranding;
177
+ /**
178
+ * The visitor's country, resolved from their IP.
179
+ *
180
+ * A GUESS and only ever a DEFAULT — nothing branches on it and it never
181
+ * reaches a verification. Deliberately not evidence: device intelligence
182
+ * carries the same lookup as a RISK signal, and the two must not be confused.
183
+ */
184
+ geoCountry?: string | null;
144
185
  /** KYB only: the mapped applicant workflow, when configured and resolvable. */
145
186
  applicantWorkflow?: ApplicantWorkflowPayload | null;
146
187
  }
@@ -157,8 +198,123 @@ export interface ContactCheckResponse {
157
198
  token: string;
158
199
  }
159
200
 
201
+ /**
202
+ * A server-issued Active-Authentication challenge. `challenge` is base64 of the
203
+ * 8 bytes handed to the chip; `challengeId` is what rides the submission so the
204
+ * server can spend it (single-use — a replayed one is refused).
205
+ */
206
+ export interface NfcChallengeResponse {
207
+ challengeId: string;
208
+ challenge: string;
209
+ expiresAt: string;
210
+ }
211
+
160
212
  export interface HealthResponse {
161
213
  status: string;
162
214
  }
163
215
 
164
216
  // The mime types the server accepts (must mirror the server's upload allowlist).
217
+
218
+ /** `POST /session/start` — mint (or resume) an attempt session. */
219
+ export interface SessionStartResponse {
220
+ sessionId: string;
221
+ expiresAt: string;
222
+ resumed: boolean;
223
+ /** The session's own hosted web page. After a KYB submission it is the
224
+ * rehydrated success screen with every key person's invite link — the
225
+ * applicant's way back to those links once the app closes. */
226
+ url?: string;
227
+ /** Where the user got to, when resuming. Media references are already
228
+ * pruned server-side of anything that has since expired. */
229
+ progress?: {
230
+ step?: string;
231
+ mediaIds?: Record<string, string>;
232
+ data?: Record<string, unknown>;
233
+ };
234
+ }
235
+
236
+ /** One person a submitted KYB application is still waiting on (the server's
237
+ * reconciled view — registry discovery can add people the applicant never
238
+ * listed, so this list supersedes the submit-time invites). */
239
+ export interface AwaitingPersonPayload {
240
+ id: string;
241
+ name: string;
242
+ role: string;
243
+ ownershipPct: number | null;
244
+ /** ISO-2, or null when the register gave free text no flag matches. */
245
+ country: string | null;
246
+ status: 'verified' | 'failed' | 'submitted' | 'pending' | 'not_needed';
247
+ /** Null once their check is done, or when they never needed one. */
248
+ inviteUrl: string | null;
249
+ isApplicant: boolean;
250
+ /** A company completes a KYB application, not a KYC - the list labels it so. */
251
+ isCorporate?: boolean;
252
+ }
253
+
254
+ /** `GET /session/:sessionId/summary` — a submitted session, rebuilt server-side. */
255
+ export interface SessionSummaryResponse {
256
+ status: 'completed';
257
+ /** False while registry discovery is still reconciling the people list. */
258
+ keyPeopleSettled?: boolean;
259
+ keyPeople: AwaitingPersonPayload[];
260
+ }
261
+
262
+ /** One officer as the register names them — the key-people prefill's input. */
263
+ export interface RegistryOfficer {
264
+ name: string | null;
265
+ designation: string | null;
266
+ /** Everything else the register said about them (older servers omit these). */
267
+ roles?: string[] | null;
268
+ ownershipPct?: number | null;
269
+ email?: string | null;
270
+ isCorporate?: boolean | null;
271
+ registrationNumber?: string | null;
272
+ }
273
+
274
+ /** What the register holds about a company, when it answered. Everything is
275
+ * nullable because no register answers all of it for every company. */
276
+ export interface BusinessCompanyRecord {
277
+ name: string | null;
278
+ registrationNumber: string;
279
+ registrationDate: string | null;
280
+ typeOfEntity: string | null;
281
+ companyStatus: string | null;
282
+ address: string | null;
283
+ email: string | null;
284
+ phone: string | null;
285
+ taxId: string | null;
286
+ vatNumber: string | null;
287
+ natureOfBusiness: string | null;
288
+ city: string | null;
289
+ state: string | null;
290
+ }
291
+
292
+ /** `POST /business/select` — the paid registry check at selection. */
293
+ export interface BusinessSelectResponse {
294
+ checked: boolean;
295
+ /** Why the pre-flight did not run (`checked: false`) — the submit-time lookup
296
+ * still happens, so this is informational, never an error. */
297
+ reason?: string;
298
+ found?: boolean;
299
+ charged?: boolean;
300
+ business?: (BusinessCompanyRecord & { keyPeople: RegistryOfficer[] }) | null;
301
+ }
302
+
303
+ /** One candidate from the FREE name search — picking one is what runs the paid check. */
304
+ export interface BusinessSearchHit {
305
+ name: string;
306
+ registrationNumber: string;
307
+ status?: string;
308
+ }
309
+
310
+ /** `GET /business/search` — find a business by name. */
311
+ export interface BusinessSearchResponse {
312
+ results: BusinessSearchHit[];
313
+ /** Which source answered; a degraded fallback names itself here. */
314
+ source: string;
315
+ }
316
+
317
+ /** `GET /business/regions` — the registry regions of a split register. */
318
+ export interface BusinessRegionsResponse {
319
+ regions: { code: string; name: string }[];
320
+ }
@@ -10,7 +10,26 @@
10
10
  export interface VerifyRequest {
11
11
  country: string;
12
12
  idType: string;
13
+ /** The attempt session this run happened under — the verification adopts its
14
+ * id, and a registry check paid at selection is not paid again at submit. */
15
+ sessionId?: string;
13
16
  idNumber?: string;
17
+ /**
18
+ * Multi-ID: every check in the run, in pick order (2–3). ONE verification
19
+ * comes back, judged by the workflow's pass policy. The top-level
20
+ * idType/idNumber mirror the first entry.
21
+ */
22
+ idChecks?: Array<{
23
+ idType: string;
24
+ idNumber?: string;
25
+ documentFront?: string;
26
+ documentBack?: string;
27
+ /** Each check's OWN document recording. */
28
+ documentFrontVideo?: string;
29
+ documentBackVideo?: string;
30
+ /** This check's own chip read — the chip belongs to a PARTICULAR document. */
31
+ nfc?: VerifyRequest['nfc'];
32
+ }>;
14
33
  /** The org's user reference → Entity.externalUserId at the seam (not matched). */
15
34
  userId?: string;
16
35
  userData?: {
@@ -50,6 +69,13 @@ export interface VerifyRequest {
50
69
  email?: string;
51
70
  phone?: string;
52
71
  website?: string;
72
+ /** Registry facts the applicant states (the extended collectCompanyInfo
73
+ * fields) — where they differ from the register, that is the finding. */
74
+ dateOfIncorporation?: string;
75
+ taxId?: string;
76
+ vatNumber?: string;
77
+ companyType?: string;
78
+ natureOfBusiness?: string;
53
79
  /** Uploaded supporting documents (honoured only when the workflow's
54
80
  * `business.documents` block configures them). */
55
81
  documents?: Array<{ type: string; mediaId: string }>;
@@ -77,7 +103,33 @@ export interface VerifyRequest {
77
103
  * signer — and only the server may conclude a chip is genuine. `chipAuth`
78
104
  * reports how the chip was unlocked and is informational.
79
105
  */
80
- nfc?: { dg1: string; sod?: string; dg2?: string; chipAuth?: string };
106
+ nfc?: {
107
+ dg1: string;
108
+ sod?: string;
109
+ dg2?: string;
110
+ dg7?: string;
111
+ dg11?: string;
112
+ dg12?: string;
113
+ /**
114
+ * DG15 (the chip's Active-Authentication public key) and its signature over
115
+ * the challenge the server issued — the ANTI-CLONE proof. Passive
116
+ * authentication proves the issuing state signed this data; only these
117
+ * prove it is the chip they signed it onto. Verified server-side against a
118
+ * SOD-bound DG15: a client that checked its own chip could be patched.
119
+ */
120
+ dg15?: string;
121
+ aaChallengeId?: string;
122
+ aaSignature?: string;
123
+ chipAuth?: string;
124
+ /**
125
+ * WHY the session is on that protocol. A chip reading over BAC because it
126
+ * offers no PACE is nothing to act on; one reading over BAC because our
127
+ * PACE broke is a bug, and `chipAuth` alone cannot tell them apart.
128
+ * Diagnostic only — the server records it and never judges on it.
129
+ */
130
+ paceOutcome?: string;
131
+ paceDetail?: string;
132
+ };
81
133
  /** The workflow that drove this flow — attributes the submission to it. */
82
134
  workflowId?: string;
83
135
  /**
@@ -1,10 +1,16 @@
1
1
  import { SDK_VERSION } from './deviceMetadata';
2
2
  import type {
3
+ BusinessRegionsResponse,
4
+ BusinessSearchResponse,
5
+ BusinessSelectResponse,
3
6
  ContactCheckResponse,
7
+ NfcChallengeResponse,
4
8
  ContactSendResponse,
5
9
  HealthResponse,
6
10
  MediaUploadType,
7
11
  SdkConfigResponse,
12
+ SessionStartResponse,
13
+ SessionSummaryResponse,
8
14
  UploadFile,
9
15
  UploadResponse,
10
16
  VerificationStatusResponse,
@@ -234,6 +240,18 @@ export function createKYCApi(baseUrl: string, apiKey: string) {
234
240
  });
235
241
  },
236
242
 
243
+ /**
244
+ * A fresh Active-Authentication challenge for a chip read — the nonce the
245
+ * chip signs to prove it is the original document rather than a copy of
246
+ * one. It has to come from the SERVER: a nonce the client chose would let
247
+ * a captured signature be replayed forever, which is the clone the check
248
+ * exists to catch. Best-effort by contract at every call site — a chip
249
+ * read without one is exactly the read we did before.
250
+ */
251
+ async nfcChallenge(): Promise<NfcChallengeResponse> {
252
+ return request<NfcChallengeResponse>('/nfc/challenge', { method: 'POST' });
253
+ },
254
+
237
255
  async config(signal?: AbortSignal): Promise<SdkConfigResponse> {
238
256
  return request<SdkConfigResponse>('/config', signal ? { signal } : {});
239
257
  },
@@ -250,6 +268,92 @@ export function createKYCApi(baseUrl: string, apiKey: string) {
250
268
  );
251
269
  },
252
270
 
271
+ /**
272
+ * Begin (or resume) a verification ATTEMPT session. Best-effort by
273
+ * contract: sessions power resumability, the dashboard's live attempt
274
+ * view, and the registry check at selection — verifying is never
275
+ * conditional on one existing.
276
+ */
277
+ async startSession(input: {
278
+ externalUserId?: string;
279
+ workflowId?: string;
280
+ /** Persistent device id — the anonymous-mount resume fallback. */
281
+ deviceRef?: string;
282
+ }): Promise<SessionStartResponse> {
283
+ return request<SessionStartResponse>('/session/start', {
284
+ method: 'POST',
285
+ body: JSON.stringify(input),
286
+ });
287
+ },
288
+
289
+ /**
290
+ * A submitted session, rebuilt server-side — the reconciled key-people
291
+ * list the success screen polls. Same body a hosted link reads by token,
292
+ * so mobile and hosted success screens cannot tell different stories
293
+ * about one application.
294
+ */
295
+ async sessionSummary(sessionId: string): Promise<SessionSummaryResponse> {
296
+ return request<SessionSummaryResponse>(`/session/${sessionId}/summary`);
297
+ },
298
+
299
+ /**
300
+ * Save where the user has got to. Losing a save costs some re-typing on
301
+ * resume and must never interrupt them now — callers swallow failures.
302
+ */
303
+ async saveProgress(sessionId: string, progress: unknown): Promise<void> {
304
+ await request(`/session/${encodeURIComponent(sessionId)}/progress`, {
305
+ method: 'PUT',
306
+ body: JSON.stringify(progress),
307
+ });
308
+ },
309
+
310
+ /**
311
+ * Find a business by name. FREE — no provider charge here or upstream, so
312
+ * the applicant may look as many times as they need. Throws on a provider
313
+ * failure so the caller can show "unavailable" rather than an empty list,
314
+ * which would read as "this business is not registered".
315
+ */
316
+ async businessSearch(params: {
317
+ country: string;
318
+ subdivisionCode?: string;
319
+ query: string;
320
+ limit?: number;
321
+ }): Promise<BusinessSearchResponse> {
322
+ const qs = new URLSearchParams({ country: params.country, query: params.query });
323
+ if (params.subdivisionCode) qs.set('subdivisionCode', params.subdivisionCode);
324
+ if (params.limit) qs.set('limit', String(params.limit));
325
+ return request<BusinessSearchResponse>(`/business/search?${qs.toString()}`);
326
+ },
327
+
328
+ /** Registry regions for a country. Empty when it has a single register. */
329
+ async businessRegions(country: string): Promise<BusinessRegionsResponse> {
330
+ return request<BusinessRegionsResponse>(
331
+ `/business/regions?country=${encodeURIComponent(country)}`,
332
+ );
333
+ },
334
+
335
+ /**
336
+ * The PAID registry check for the company the applicant identified, run at
337
+ * selection so the register's key people come back BEFORE the form asks
338
+ * for them. Never fails the flow: a short balance, unconfigured pricing or
339
+ * a spent lookup budget returns `checked: false` and the lookup happens at
340
+ * submit as before.
341
+ */
342
+ async businessSelect(body: {
343
+ sessionId: string;
344
+ country: string;
345
+ subdivisionCode?: string;
346
+ product?: string;
347
+ registrationNumber: string;
348
+ registrationName?: string;
349
+ sandboxOutcome?: string;
350
+ }): Promise<BusinessSelectResponse> {
351
+ return request<BusinessSelectResponse>('/business/select', {
352
+ method: 'POST',
353
+ body: JSON.stringify(body),
354
+ });
355
+ },
356
+
253
357
  async health(): Promise<HealthResponse> {
254
358
  // Public endpoint — no auth needed, but the shared headers are harmless.
255
359
  return request<HealthResponse>('/health');
@@ -15,7 +15,7 @@ export type DeviceType = 'mobile' | 'tablet' | 'desktop' | 'unknown';
15
15
  * Single source of truth for the SDK version — also used by `services/api.ts`
16
16
  * for the `X-SDK-Version` header. Keep in sync with `package.json`.
17
17
  */
18
- export const SDK_VERSION = '2.4.0';
18
+ export const SDK_VERSION = '2.5.0';
19
19
 
20
20
  export interface ReactNativeDeviceMetadata {
21
21
  sdkType: 'react-native';
@@ -74,8 +74,16 @@ export function mapToKycError(err: unknown, context: ErrorContext): KYCError {
74
74
  : err.message || 'This verification feature is currently disabled for your organization.';
75
75
  return new KYCError('feature_disabled', message);
76
76
  }
77
- if (err.statusCode >= 500 || err.statusCode === 0) {
78
- // Transient server error that survived retries.
77
+ // Nothing came back at all — DNS, routing, a refused connection. Kept
78
+ // SEPARATE from a 5xx: telling somebody the server errored when their
79
+ // request never arrived sends them looking through server logs for a
80
+ // request that was never made.
81
+ if (err.statusCode === 0) {
82
+ const code: KYCErrorCode = context === 'upload' ? 'upload_failed' : 'network_error';
83
+ return new KYCError(code, "Couldn't reach the server. Check your connection and try again.");
84
+ }
85
+ if (err.statusCode >= 500) {
86
+ // Transient server error that survived retries — the request DID arrive.
79
87
  const code: KYCErrorCode = context === 'upload' ? 'upload_failed' : 'network_error';
80
88
  return new KYCError(code, 'A server error occurred. Please try again in a moment.');
81
89
  }
@@ -102,6 +102,7 @@ export async function resolveWorkflow(
102
102
  status: 'ready',
103
103
  idTypes: res.idTypes,
104
104
  branding: res.branding,
105
+ geoCountry: res.geoCountry,
105
106
  environment: res.environment,
106
107
  fatal: false,
107
108
  },
@@ -51,4 +51,33 @@ export interface MyazaFaceDetector extends HybridObject<{ ios: 'swift'; android:
51
51
  * `faceCount: 0` when no face is present.
52
52
  */
53
53
  detectFace(frame: Frame): FaceResult;
54
+
55
+ /**
56
+ * Whether the detector can run RIGHT NOW.
57
+ *
58
+ * Android fetches its model through Google Play Services rather than bundling
59
+ * it (see android/build.gradle), so there is a window — first launch, or a
60
+ * device with no GMS at all — where detection cannot work. This MUST be
61
+ * checked before the camera opens: `detectFace` can only answer in
62
+ * `FaceResult`, where "model missing" and "no face in frame" are the same
63
+ * `faceCount: 0`, so relying on it would leave the user staring at
64
+ * "position your face" forever with nothing to explain why.
65
+ *
66
+ * iOS is always true — Apple Vision is a system framework with nothing to
67
+ * fetch.
68
+ */
69
+ isModelReady(): boolean;
70
+
71
+ /**
72
+ * Ask Play Services to download the model, resolving `true` once it is usable
73
+ * and `false` if it cannot be obtained (no GMS, no network, user declined).
74
+ *
75
+ * Call this EARLY — the SDK primes it at flow start, so the download overlaps
76
+ * the consent and ID-type screens and the model is warm by the time liveness
77
+ * runs. This mirrors the web SDK, which calls `primeFaceMesh()` on mount for
78
+ * exactly the same reason.
79
+ *
80
+ * Safe to call repeatedly; resolves immediately when already ready.
81
+ */
82
+ prepareModel(): Promise<boolean>;
54
83
  }
@@ -0,0 +1,113 @@
1
+ import type { BusinessCheckResult, KycState } from './state';
2
+ import { EMPTY_BUSINESS_CHECK } from './state';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // The paid registry check at SELECTION, split from kycStore.ts (200-line rule).
6
+ //
7
+ // Run when the applicant confirms their company, so the register answers
8
+ // BEFORE the details screen asks them to confirm what it said — and its key
9
+ // people arrive before that step asks for them. Only a definitive "not on the
10
+ // register" stops the flow: everything else (a short balance, an outage, a
11
+ // spent lookup budget) continues and is checked at submission, exactly as it
12
+ // was before this existed. Mirrors the web SDK's useBusinessCheck — keep the
13
+ // two in lockstep.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ type Set = (partial: Partial<KycState> | ((s: KycState) => Partial<KycState>)) => void;
17
+
18
+ export async function runBusinessCheck(
19
+ get: () => KycState,
20
+ set: Set,
21
+ ): Promise<BusinessCheckResult> {
22
+ const s = get();
23
+ const registrationNumber = s.business.registrationNumber?.trim();
24
+ if (!registrationNumber) return { canContinue: true, company: null };
25
+
26
+ // Already checked this exact company — do not pay to be told again.
27
+ //
28
+ // Only a SETTLED answer is reused. 'unavailable' is deliberately not one: an
29
+ // outage said nothing about the company, so a repeat press retries the
30
+ // register rather than replaying the outage. And a remembered answer keeps
31
+ // its meaning — a stored not_found still blocks.
32
+ const normalized = registrationNumber.toUpperCase();
33
+ const settled =
34
+ s.businessCheck.status !== 'idle' &&
35
+ s.businessCheck.status !== 'checking' &&
36
+ s.businessCheck.status !== 'unavailable';
37
+ if (s.businessCheck.checkedNumber === normalized && settled) {
38
+ return {
39
+ canContinue: s.businessCheck.status !== 'not_found',
40
+ company: s.businessCheck.company,
41
+ };
42
+ }
43
+
44
+ // No session means no anchor for the charge, so there is nothing to run
45
+ // against. The check happens at submission, exactly as it did before.
46
+ if (!s.sessionId) return { canContinue: true, company: null };
47
+
48
+ set((cur) => ({
49
+ businessCheck: { ...cur.businessCheck, status: 'checking', checkedNumber: normalized },
50
+ }));
51
+ try {
52
+ const res = await s.api.businessSelect({
53
+ sessionId: s.sessionId,
54
+ country: s.business.country || s.config.business?.country || '',
55
+ ...(s.business.subdivisionCode.trim()
56
+ ? { subdivisionCode: s.business.subdivisionCode.trim() }
57
+ : {}),
58
+ ...(s.business.registrationName.trim()
59
+ ? { registrationName: s.business.registrationName.trim() }
60
+ : {}),
61
+ ...(s.business.product ? { product: s.business.product } : {}),
62
+ registrationNumber,
63
+ });
64
+
65
+ if (!res.checked) {
66
+ // The organisation could not be charged. Not the applicant's problem and
67
+ // not something they can fix, so it is not shown as an error — the flow
68
+ // continues and the check runs at submission. `lookup_limit_reached` is
69
+ // the one they DID cause, by re-picking company after company, and the
70
+ // one they can act on: check the number rather than keep trying.
71
+ set((cur) => ({
72
+ businessCheck: {
73
+ ...cur.businessCheck,
74
+ status: res.reason === 'lookup_limit_reached' ? 'limit_reached' : 'skipped',
75
+ },
76
+ }));
77
+ return { canContinue: true, company: null };
78
+ }
79
+
80
+ if (!res.found) {
81
+ // A definitive "not on the register" is worth stopping for: continuing
82
+ // would spend the applicant's time on documents for a company that will
83
+ // fail anyway.
84
+ set((cur) => ({
85
+ businessCheck: { ...cur.businessCheck, status: 'not_found', company: null, officers: [] },
86
+ }));
87
+ return { canContinue: false, company: null };
88
+ }
89
+
90
+ const { keyPeople, ...company } = res.business!;
91
+ set((cur) => ({
92
+ businessCheck: {
93
+ ...cur.businessCheck,
94
+ status: 'found',
95
+ company,
96
+ officers: keyPeople ?? [],
97
+ },
98
+ }));
99
+ return { canContinue: true, company };
100
+ } catch {
101
+ // A register outage is NOT "this company does not exist" — telling the
102
+ // applicant their business is unregistered on the strength of a 503 is the
103
+ // one wrong answer here. Retryable, and it never blocks: the check still
104
+ // happens at submission.
105
+ set((cur) => ({ businessCheck: { ...cur.businessCheck, status: 'unavailable' } }));
106
+ return { canContinue: true, company: null };
107
+ }
108
+ }
109
+
110
+ /** Back to square one — called when WHICH company this is about changes. */
111
+ export function resetBusinessCheck(set: Set): void {
112
+ set({ businessCheck: { ...EMPTY_BUSINESS_CHECK } });
113
+ }
@@ -76,6 +76,10 @@ export function stepOrderOptions(state: KycState): StepOrderOptions {
76
76
  hasPhoneVerification: config.phoneVerification?.enabled === true,
77
77
  hasPoa: hasProofOfAddressStep(config.proofOfAddress),
78
78
  hasQuestionnaire: hasActiveQuestionnaire(config.questionnaire),
79
+ // Present only on a session a reviewer sent back. Narrows the order to the
80
+ // steps they ticked, so somebody fixing one blurry photo is not walked
81
+ // through the whole flow again.
82
+ resubmit: config.resubmit,
79
83
  };
80
84
  }
81
85
 
@@ -148,6 +152,15 @@ export function businessSubmission(state: KycState): {
148
152
  ...(text(b.email) ? { email: text(b.email) } : {}),
149
153
  ...(text(b.phone) ? { phone: text(b.phone) } : {}),
150
154
  ...(text(b.website) ? { website: text(b.website) } : {}),
155
+ // The five registry facts, sent only when filled. The server drops any
156
+ // the workflow has switched off, so an over-send is validate-and-drop
157
+ // rather than an error, and an under-send is what the required-field
158
+ // 422 is for.
159
+ ...(text(b.dateOfIncorporation) ? { dateOfIncorporation: text(b.dateOfIncorporation) } : {}),
160
+ ...(text(b.taxId) ? { taxId: text(b.taxId) } : {}),
161
+ ...(text(b.vatNumber) ? { vatNumber: text(b.vatNumber) } : {}),
162
+ ...(text(b.companyType) ? { companyType: text(b.companyType) } : {}),
163
+ ...(text(b.natureOfBusiness) ? { natureOfBusiness: text(b.natureOfBusiness) } : {}),
151
164
  // Extras ride along ONLY when the workflow configures them — the server
152
165
  // ignores an unconfigured block, but sending one is a claim we did not
153
166
  // collect properly.
@@ -155,6 +168,8 @@ export function businessSubmission(state: KycState): {
155
168
  ? { documents: app.documents.map((d) => ({ type: d.type, mediaId: d.mediaId })) }
156
169
  : {}),
157
170
  ...(hasKeyPeopleCollection(config) && people.length > 0 ? { keyPeople: people } : {}),
171
+ // The FATF fallback, attested: no natural person qualifies as a UBO.
172
+ ...(app.uboUnidentifiable ? { uboUnidentifiable: true } : {}),
158
173
  ...(hasApplicantVerification(config) && app.applicantRole
159
174
  ? {
160
175
  applicant: {