@myazahq/kyc-sdk-react-native 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -7
- package/src/components/GlassIconButton.tsx +2 -5
- package/src/components/Icon.tsx +17 -14
- package/src/components/KycFlow.tsx +1 -0
- package/src/components/KycSheet.tsx +0 -1
- package/src/components/StepView.tsx +3 -0
- package/src/components/icons/glyphs.ts +35 -0
- package/src/components/icons/index.ts +3 -0
- package/src/components/icons/map.ts +165 -0
- package/src/components/icons/names.ts +86 -0
- package/src/components/stepHeaderMeta.tsx +15 -0
- package/src/config/stepOrder.ts +20 -5
- package/src/config/supportingDocuments.ts +131 -0
- package/src/config/theme.ts +29 -3
- package/src/config/workflowMerge.ts +13 -0
- package/src/lib/supportingDocumentsIntro.ts +35 -0
- package/src/screens/BusinessDocumentSlot.tsx +37 -8
- package/src/screens/SupportingDocumentCard.tsx +134 -0
- package/src/screens/SupportingDocumentsStep.tsx +203 -0
- package/src/screens/consent/model.ts +22 -5
- package/src/screens/liveness/CaptureRing.tsx +31 -17
- package/src/screens/supportingDocumentParts.tsx +126 -0
- package/src/screens/useBusinessDocumentAttach.ts +20 -9
- package/src/services/api-types.ts +3 -0
- package/src/services/deviceMetadata.ts +1 -1
- package/src/store/derive.ts +11 -0
- package/src/store/kycStore.ts +15 -0
- package/src/store/session.ts +18 -0
- package/src/store/state.ts +26 -0
- package/src/store/submit.ts +11 -0
- package/src/types/config.ts +10 -0
- package/src/types/workflow.ts +39 -0
- package/src/components/icon-map.ts +0 -176
|
@@ -5,6 +5,7 @@ import { fillTokens } from '../../utils/tokens';
|
|
|
5
5
|
import { hasEmailVerificationStep, hasPhoneVerificationStep } from '../../config/contact';
|
|
6
6
|
import { hasActiveQuestionnaire } from '../../config/questionnaire';
|
|
7
7
|
import { hasProofOfAddressStep } from '../../config/proofOfAddress';
|
|
8
|
+
import { mayAskSupportingDocuments } from '../../config/supportingDocuments';
|
|
8
9
|
import { hasAddressCollectionStep } from '../../config/addressCollection';
|
|
9
10
|
import {
|
|
10
11
|
hasApplicantVerification,
|
|
@@ -70,11 +71,17 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
|
|
70
71
|
'We need to re-confirm the email address and phone number on your account. This takes a minute and is secure.',
|
|
71
72
|
};
|
|
72
73
|
|
|
74
|
+
// The address scope's bullets are NOT a fixed pair: it verifies an address by
|
|
75
|
+
// the pin, by a document, or by both, so promising a map on a flow that only
|
|
76
|
+
// asks for a document is a promise the flow never keeps. Gated below on the
|
|
77
|
+
// same step-order predicate the flow itself walks; the document's own bullet is
|
|
78
|
+
// appended by the shared post-capture block, like every other flow's.
|
|
79
|
+
const ADDRESS_PIN_BULLETS: ConsentProcessStep[] = [
|
|
80
|
+
{ icon: 'map-pin-house', label: 'Pin your home address on a map' },
|
|
81
|
+
{ icon: 'badge-check', label: 'Confirm the details only you can know' },
|
|
82
|
+
];
|
|
83
|
+
|
|
73
84
|
const SCOPE_BULLETS: Record<string, ConsentProcessStep[]> = {
|
|
74
|
-
address: [
|
|
75
|
-
{ icon: 'map-pin-house', label: 'Pin your home address on a map' },
|
|
76
|
-
{ icon: 'badge-check', label: 'Confirm the details only you can know' },
|
|
77
|
-
],
|
|
78
85
|
'biometric-authentication': [
|
|
79
86
|
{ icon: 'scan-face', label: 'Take a quick selfie with liveness checks' },
|
|
80
87
|
{ icon: 'badge-check', label: 'We match it against your enrolled face' },
|
|
@@ -123,7 +130,11 @@ export function buildConsentModel(config: MyazaKYCConfig): ConsentModel {
|
|
|
123
130
|
: scope
|
|
124
131
|
? // COPY the catalogue entry: pushing below would otherwise mutate the
|
|
125
132
|
// shared constant, appending one more bullet per re-render.
|
|
126
|
-
|
|
133
|
+
scope === 'address'
|
|
134
|
+
? hasAddressCollectionStep(config.addressCollection)
|
|
135
|
+
? [...ADDRESS_PIN_BULLETS]
|
|
136
|
+
: []
|
|
137
|
+
: [...(SCOPE_BULLETS[scope] ?? [])]
|
|
127
138
|
: [
|
|
128
139
|
{ icon: 'badge-check', label: 'Verify your government-issued ID' },
|
|
129
140
|
{ icon: 'user', label: 'Collect basic personal information' },
|
|
@@ -153,6 +164,12 @@ export function buildConsentModel(config: MyazaKYCConfig): ConsentModel {
|
|
|
153
164
|
// Post-capture features, in the order the flow runs them. Each is gated on
|
|
154
165
|
// the flow that actually asks for it, and skipped where a scope's own
|
|
155
166
|
// catalogue bullet already covers the same step.
|
|
167
|
+
// `mayAsk`, NOT the step-order predicate: that one resolves against the
|
|
168
|
+
// verified IDs and consent runs before an ID is picked, so every scoped
|
|
169
|
+
// document would answer "nothing to ask for" and go undisclosed.
|
|
170
|
+
if (!isBusiness && mayAskSupportingDocuments(config.supportingDocuments)) {
|
|
171
|
+
steps.push({ icon: 'file-text', label: 'Upload supporting documents' });
|
|
172
|
+
}
|
|
156
173
|
if (!isBusiness && hasProofOfAddressStep(config.proofOfAddress)) {
|
|
157
174
|
steps.push({ icon: 'file-text', label: 'Upload a proof of address document' });
|
|
158
175
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useEffect, useRef } from 'react';
|
|
2
|
-
import Svg, { Circle } from 'react-native-svg';
|
|
2
|
+
import Svg, { Circle, G } from 'react-native-svg';
|
|
3
3
|
import { advanceTarget, easeToward, mixHex } from '../../lib/captureRing';
|
|
4
4
|
|
|
5
5
|
// A single line traced around the camera circle's edge for the length of the
|
|
@@ -69,23 +69,37 @@ export function CaptureRing({
|
|
|
69
69
|
style={{ position: 'absolute', top: 0, left: 0 }}
|
|
70
70
|
width={size}
|
|
71
71
|
height={size}
|
|
72
|
-
// Static origin, not motion: the arc starts at twelve o'clock.
|
|
73
|
-
rotation={-90}
|
|
74
|
-
originX={size / 2}
|
|
75
|
-
originY={size / 2}
|
|
76
72
|
>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
73
|
+
{/* Static origin, not motion: the arc starts AND closes at twelve
|
|
74
|
+
o'clock, matching Flutter's `drawArc(rect, -pi / 2, ...)`.
|
|
75
|
+
|
|
76
|
+
The rotation sits on a <G>, not on the root <Svg>. react-native-svg
|
|
77
|
+
silently drops transform props on the root: its render applies a
|
|
78
|
+
transform only `if (transform)` — a `rotation`/`originX`/`originY`
|
|
79
|
+
triple leaves that undefined, so the branch never runs — and the
|
|
80
|
+
inner group it wraps children in is built from style/fill/stroke
|
|
81
|
+
props alone, so nothing forwards them there either. `SvgProps
|
|
82
|
+
extends GProps`, so the compiler accepts it and the arc quietly
|
|
83
|
+
starts at three o'clock, which is where the web SDK's CSS
|
|
84
|
+
`-rotate-90` would have put it had CSS applied here.
|
|
85
|
+
|
|
86
|
+
It is also deliberately not on the <Circle>: that node takes a
|
|
87
|
+
setNativeProps write every frame, and the transform has no business
|
|
88
|
+
sharing a node with the animation. */}
|
|
89
|
+
<G rotation={-90} originX={size / 2} originY={size / 2}>
|
|
90
|
+
<Circle
|
|
91
|
+
ref={ref}
|
|
92
|
+
cx={size / 2}
|
|
93
|
+
cy={size / 2}
|
|
94
|
+
r={r}
|
|
95
|
+
fill="none"
|
|
96
|
+
stroke={color}
|
|
97
|
+
strokeWidth={STROKE}
|
|
98
|
+
strokeLinecap="butt"
|
|
99
|
+
strokeDasharray={[circumference]}
|
|
100
|
+
strokeDashoffset={circumference}
|
|
101
|
+
/>
|
|
102
|
+
</G>
|
|
89
103
|
</Svg>
|
|
90
104
|
);
|
|
91
105
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { useTheme } from '../components/runtime';
|
|
6
|
+
import { MyazaText } from '../components/Typography';
|
|
7
|
+
import { Icon } from '../components/Icon';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// The pieces of a supporting-document card.
|
|
11
|
+
//
|
|
12
|
+
// Split out of the card itself only for the file-length rule; they are that
|
|
13
|
+
// card's own furniture and nothing else builds them. The Flutter SDK splits
|
|
14
|
+
// the same three into supporting_document_parts.dart, for the same reason.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
const MARKER = 28;
|
|
18
|
+
|
|
19
|
+
/** A count while the document is outstanding, and a state anybody can read once
|
|
20
|
+
* it is not. One document needs no number, so it wears a document glyph. */
|
|
21
|
+
export function DocumentMarker({
|
|
22
|
+
done,
|
|
23
|
+
position,
|
|
24
|
+
total,
|
|
25
|
+
}: {
|
|
26
|
+
done: boolean;
|
|
27
|
+
position: number;
|
|
28
|
+
total: number;
|
|
29
|
+
}): React.ReactElement {
|
|
30
|
+
const { colors } = useTheme();
|
|
31
|
+
return (
|
|
32
|
+
<View
|
|
33
|
+
style={{
|
|
34
|
+
width: MARKER,
|
|
35
|
+
height: MARKER,
|
|
36
|
+
borderRadius: radius.full,
|
|
37
|
+
alignItems: 'center',
|
|
38
|
+
justifyContent: 'center',
|
|
39
|
+
backgroundColor: done ? colors.primary : colors.primary100,
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{done ? (
|
|
43
|
+
<Icon name="check" size={16} color={colors.onPrimary} />
|
|
44
|
+
) : total > 1 ? (
|
|
45
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '700' }}>
|
|
46
|
+
{String(position)}
|
|
47
|
+
</MyazaText>
|
|
48
|
+
) : (
|
|
49
|
+
<Icon name="file-text" size={14} color={colors.primary} />
|
|
50
|
+
)}
|
|
51
|
+
</View>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Required or optional, in a word as well as a colour. */
|
|
56
|
+
export function DocumentStatePill({ required }: { required: boolean }): React.ReactElement {
|
|
57
|
+
const { colors } = useTheme();
|
|
58
|
+
return (
|
|
59
|
+
<View
|
|
60
|
+
style={{
|
|
61
|
+
paddingHorizontal: spacing.sm,
|
|
62
|
+
paddingVertical: 2,
|
|
63
|
+
borderRadius: radius.full,
|
|
64
|
+
backgroundColor: required ? colors.errorBg : colors.background,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<MyazaText
|
|
68
|
+
variant="bodySmall"
|
|
69
|
+
color={required ? colors.error : colors.textSecondary}
|
|
70
|
+
style={{ fontWeight: '600' }}
|
|
71
|
+
>
|
|
72
|
+
{required ? 'Required' : 'Optional'}
|
|
73
|
+
</MyazaText>
|
|
74
|
+
</View>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What the server will take off this document, named as the author named it. */
|
|
79
|
+
export function DocumentReads({ reads }: { reads: readonly string[] }): React.ReactElement {
|
|
80
|
+
const { colors } = useTheme();
|
|
81
|
+
return (
|
|
82
|
+
<View
|
|
83
|
+
style={{
|
|
84
|
+
padding: spacing.sm + 4,
|
|
85
|
+
borderRadius: radius.sm,
|
|
86
|
+
backgroundColor: colors.background,
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<MyazaText
|
|
90
|
+
variant="bodySmall"
|
|
91
|
+
color={colors.textSecondary}
|
|
92
|
+
style={{ fontWeight: '600', letterSpacing: 0.5 }}
|
|
93
|
+
>
|
|
94
|
+
WHAT WE READ FROM IT
|
|
95
|
+
</MyazaText>
|
|
96
|
+
<View
|
|
97
|
+
style={{
|
|
98
|
+
marginTop: spacing.sm,
|
|
99
|
+
flexDirection: 'row',
|
|
100
|
+
flexWrap: 'wrap',
|
|
101
|
+
gap: spacing.xs + 2,
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{reads.map((read) => (
|
|
105
|
+
<View
|
|
106
|
+
key={read}
|
|
107
|
+
style={{
|
|
108
|
+
flexDirection: 'row',
|
|
109
|
+
alignItems: 'center',
|
|
110
|
+
paddingHorizontal: spacing.sm,
|
|
111
|
+
paddingVertical: 4,
|
|
112
|
+
borderRadius: radius.full,
|
|
113
|
+
borderWidth: 1,
|
|
114
|
+
borderColor: colors.border,
|
|
115
|
+
backgroundColor: colors.backgroundSecondary,
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<Icon name="check" size={12} color={colors.primary} />
|
|
119
|
+
<View style={{ width: 4 }} />
|
|
120
|
+
<MyazaText variant="bodySmall">{read}</MyazaText>
|
|
121
|
+
</View>
|
|
122
|
+
))}
|
|
123
|
+
</View>
|
|
124
|
+
</View>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -14,19 +14,30 @@ import { loadDocumentPicker } from '../services/documentPicker';
|
|
|
14
14
|
// the refusal names the file that was chosen (images 5 MB, PDFs 15 MB).
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The minimum a slot must carry for this hook: it reads the label for error
|
|
19
|
+
* copy and hands the slot straight back to the caller's uploader. Generalised
|
|
20
|
+
* so the supporting-documents step shares this picker rather than owning a
|
|
21
|
+
* second copy of three file sources and their size/MIME rules.
|
|
22
|
+
*/
|
|
23
|
+
export interface AttachableSlot {
|
|
24
|
+
key: string;
|
|
25
|
+
label: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AttachBusinessDocument<S extends AttachableSlot = ResolvedBusinessDocumentType> = (
|
|
29
|
+
slot: S,
|
|
19
30
|
uri: string,
|
|
20
31
|
mimeType: string | undefined,
|
|
21
32
|
name: string,
|
|
22
33
|
) => Promise<void>;
|
|
23
34
|
|
|
24
|
-
type Pick = (slot:
|
|
35
|
+
type Pick<S extends AttachableSlot> = (slot: S) => Promise<void>;
|
|
25
36
|
|
|
26
|
-
export function useBusinessDocumentAttach(
|
|
27
|
-
attach: AttachBusinessDocument
|
|
37
|
+
export function useBusinessDocumentAttach<S extends AttachableSlot = ResolvedBusinessDocumentType>(
|
|
38
|
+
attach: AttachBusinessDocument<S>,
|
|
28
39
|
setError: (message: string) => void,
|
|
29
|
-
): { takePhoto: Pick
|
|
40
|
+
): { takePhoto: Pick<S>; choosePhoto: Pick<S>; chooseFile: Pick<S> } {
|
|
30
41
|
const tooLarge = useCallback(
|
|
31
42
|
(mime: string | undefined, size: number | undefined): boolean => {
|
|
32
43
|
const message = uploadSizeError(mime, size);
|
|
@@ -36,7 +47,7 @@ export function useBusinessDocumentAttach(
|
|
|
36
47
|
[setError],
|
|
37
48
|
);
|
|
38
49
|
|
|
39
|
-
const takePhoto = useCallback<Pick
|
|
50
|
+
const takePhoto = useCallback<Pick<S>>(
|
|
40
51
|
async (slot) => {
|
|
41
52
|
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
|
42
53
|
if (!permission.granted) {
|
|
@@ -53,7 +64,7 @@ export function useBusinessDocumentAttach(
|
|
|
53
64
|
[attach, setError, tooLarge],
|
|
54
65
|
);
|
|
55
66
|
|
|
56
|
-
const choosePhoto = useCallback<Pick
|
|
67
|
+
const choosePhoto = useCallback<Pick<S>>(
|
|
57
68
|
async (slot) => {
|
|
58
69
|
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 1 });
|
|
59
70
|
const asset = result.canceled ? undefined : result.assets[0];
|
|
@@ -63,7 +74,7 @@ export function useBusinessDocumentAttach(
|
|
|
63
74
|
[attach, tooLarge],
|
|
64
75
|
);
|
|
65
76
|
|
|
66
|
-
const chooseFile = useCallback<Pick
|
|
77
|
+
const chooseFile = useCallback<Pick<S>>(
|
|
67
78
|
async (slot) => {
|
|
68
79
|
const picker = loadDocumentPicker();
|
|
69
80
|
if (!picker) {
|
|
@@ -24,6 +24,9 @@ export type MediaUploadType =
|
|
|
24
24
|
// downloaded statement, and company paperwork is almost always a scan.
|
|
25
25
|
| 'proof_of_address'
|
|
26
26
|
| 'business_document'
|
|
27
|
+
// Artefacts held on file, named by the organisation — not the
|
|
28
|
+
// identity evidence the verification is decided on. Images + PDF.
|
|
29
|
+
| 'supporting_document'
|
|
27
30
|
// Address Intelligence door / premises photo (image only).
|
|
28
31
|
| 'address_photo';
|
|
29
32
|
|
|
@@ -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 = '3.
|
|
18
|
+
export const SDK_VERSION = '3.1.0';
|
|
19
19
|
|
|
20
20
|
export interface ReactNativeDeviceMetadata {
|
|
21
21
|
sdkType: 'react-native';
|
package/src/store/derive.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
|
|
10
10
|
import { configScope } from '../lib/scope';
|
|
11
|
+
import { hasSupportingDocumentsStep, verifiedIdsFor } from '../config/supportingDocuments';
|
|
11
12
|
import { requiresDocumentCapture, supportsNfcChip } from '../config/idTypes';
|
|
12
13
|
import {
|
|
13
14
|
buildStepOrder,
|
|
@@ -83,6 +84,16 @@ export function stepOrderOptions(state: KycState): StepOrderOptions {
|
|
|
83
84
|
hasEmailVerification: config.emailVerification?.enabled === true,
|
|
84
85
|
hasPhoneVerification: config.phoneVerification?.enabled === true,
|
|
85
86
|
hasPoa: hasProofOfAddressStep(config.proofOfAddress),
|
|
87
|
+
// Resolved against the ID actually picked — that is what decides whether
|
|
88
|
+
// the step has anything to ask for.
|
|
89
|
+
hasSupportingDocuments: hasSupportingDocumentsStep(
|
|
90
|
+
config.supportingDocuments,
|
|
91
|
+
verifiedIdsFor({
|
|
92
|
+
country: state.selectedCountry ?? config.country,
|
|
93
|
+
idType: state.selectedIdType,
|
|
94
|
+
multiIdSlots: state.multiIdSlots,
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
86
97
|
hasAddressCollection: hasAddressCollectionStep(config.addressCollection),
|
|
87
98
|
// An absent search flag means no search SCREEN, never an error: the
|
|
88
99
|
// applicant places the pin by hand, the fallback every address failure
|
package/src/store/kycStore.ts
CHANGED
|
@@ -126,6 +126,7 @@ export function createKycStore(
|
|
|
126
126
|
contactChallenge: null,
|
|
127
127
|
business: EMPTY_BUSINESS,
|
|
128
128
|
businessApplication: EMPTY_BUSINESS_APPLICATION,
|
|
129
|
+
supportingDocuments: [],
|
|
129
130
|
applicantKeyPersonId: null,
|
|
130
131
|
keyPeopleInvites: [],
|
|
131
132
|
captureIntegrity: null,
|
|
@@ -423,6 +424,20 @@ export function createKycStore(
|
|
|
423
424
|
}));
|
|
424
425
|
},
|
|
425
426
|
|
|
427
|
+
setSupportingDocument(doc) {
|
|
428
|
+
set((s) => ({
|
|
429
|
+
// One upload per document, like the business slots: re-uploading
|
|
430
|
+
// replaces rather than appends.
|
|
431
|
+
supportingDocuments: [...s.supportingDocuments.filter((d) => d.type !== doc.type), doc],
|
|
432
|
+
}));
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
removeSupportingDocument(type) {
|
|
436
|
+
set((s) => ({
|
|
437
|
+
supportingDocuments: s.supportingDocuments.filter((d) => d.type !== type),
|
|
438
|
+
}));
|
|
439
|
+
},
|
|
440
|
+
|
|
426
441
|
removeBusinessDocument(type) {
|
|
427
442
|
set((s) => ({
|
|
428
443
|
businessApplication: {
|
package/src/store/session.ts
CHANGED
|
@@ -90,6 +90,17 @@ export function restoreAttemptProgress(
|
|
|
90
90
|
const s = store.getState();
|
|
91
91
|
const d = (progress.data ?? {}) as Record<string, unknown>;
|
|
92
92
|
|
|
93
|
+
// Validate-and-drop: a snapshot written by an older build must degrade to
|
|
94
|
+
// restoring less, never to breaking the flow.
|
|
95
|
+
const restoredDocs = Array.isArray(d['supportingDocuments'])
|
|
96
|
+
? (d['supportingDocuments'] as unknown[]).filter(
|
|
97
|
+
(doc): doc is { type: string; mediaId: string } =>
|
|
98
|
+
!!doc &&
|
|
99
|
+
typeof doc === 'object' &&
|
|
100
|
+
typeof (doc as { type?: unknown }).type === 'string' &&
|
|
101
|
+
typeof (doc as { mediaId?: unknown }).mediaId === 'string',
|
|
102
|
+
)
|
|
103
|
+
: null;
|
|
93
104
|
const app = d['businessApplication'] as
|
|
94
105
|
| (Partial<KycState['businessApplication']> & { keyPeople?: Array<Record<string, unknown>> })
|
|
95
106
|
| undefined;
|
|
@@ -116,6 +127,7 @@ export function restoreAttemptProgress(
|
|
|
116
127
|
...(d['business'] && typeof d['business'] === 'object'
|
|
117
128
|
? { business: { ...s.business, ...(d['business'] as object) } }
|
|
118
129
|
: {}),
|
|
130
|
+
...(restoredDocs ? { supportingDocuments: restoredDocs } : {}),
|
|
119
131
|
...(app
|
|
120
132
|
? {
|
|
121
133
|
businessApplication: {
|
|
@@ -180,6 +192,12 @@ export function progressFromState(s: ReturnType<KycStore['getState']>): Record<s
|
|
|
180
192
|
idNumber: s.idNumber || undefined,
|
|
181
193
|
business: s.business,
|
|
182
194
|
businessApplication: s.businessApplication,
|
|
195
|
+
// The uploads without their preview names: a restored attempt shows the
|
|
196
|
+
// slot as uploaded, which is what the mediaId is for.
|
|
197
|
+
supportingDocuments:
|
|
198
|
+
s.supportingDocuments.length > 0
|
|
199
|
+
? s.supportingDocuments.map((d) => ({ type: d.type, mediaId: d.mediaId }))
|
|
200
|
+
: undefined,
|
|
183
201
|
contact: s.contact,
|
|
184
202
|
questionnaireAnswers: s.questionnaireAnswers,
|
|
185
203
|
address: s.address ?? undefined,
|
package/src/store/state.ts
CHANGED
|
@@ -402,6 +402,24 @@ export interface KycState {
|
|
|
402
402
|
business: BusinessState;
|
|
403
403
|
/** The rest of the KYB application: people, documents, applicant role. */
|
|
404
404
|
businessApplication: BusinessApplicationState;
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Supporting documents the person uploaded — artefacts the org holds on
|
|
408
|
+
* file, not the identity evidence this verification is decided on.
|
|
409
|
+
* `fileName` is display only and never rides the wire or session progress.
|
|
410
|
+
*/
|
|
411
|
+
supportingDocuments: Array<{
|
|
412
|
+
type: string;
|
|
413
|
+
mediaId: string;
|
|
414
|
+
fileName?: string;
|
|
415
|
+
/**
|
|
416
|
+
* Local URI of the picked image, for the card's thumbnail. Display only,
|
|
417
|
+
* like `fileName`: it never rides the wire or session progress, so a
|
|
418
|
+
* restored attempt shows the slot uploaded without a preview.
|
|
419
|
+
*/
|
|
420
|
+
previewUri?: string;
|
|
421
|
+
isPdf?: boolean;
|
|
422
|
+
}>;
|
|
405
423
|
/**
|
|
406
424
|
* Set by a KYB submission that requires applicant verification — the
|
|
407
425
|
* KeyPerson id the applicant's own individual check links back to.
|
|
@@ -500,6 +518,14 @@ export interface KycState {
|
|
|
500
518
|
setUboUnidentifiable: (checked: boolean) => void;
|
|
501
519
|
setBusinessDocument: (doc: BusinessDocumentUpload) => void;
|
|
502
520
|
removeBusinessDocument: (type: string) => void;
|
|
521
|
+
setSupportingDocument: (doc: {
|
|
522
|
+
type: string;
|
|
523
|
+
mediaId: string;
|
|
524
|
+
fileName?: string;
|
|
525
|
+
previewUri?: string;
|
|
526
|
+
isPdf?: boolean;
|
|
527
|
+
}) => void;
|
|
528
|
+
removeSupportingDocument: (type: string) => void;
|
|
503
529
|
setApplicant: (role: ApplicantRole, name: string, keyPersonIndex?: number | null) => void;
|
|
504
530
|
setCaptureIntegrity: (integrity: CaptureIntegrity) => void;
|
|
505
531
|
setMrzScan: (scan: MrzScan) => void;
|
package/src/store/submit.ts
CHANGED
|
@@ -99,6 +99,17 @@ export function buildVerifyRequest(
|
|
|
99
99
|
: state.mediaIds,
|
|
100
100
|
...(state.config.workflowId ? { workflowId: state.config.workflowId } : {}),
|
|
101
101
|
...(state.poaDocumentType ? { proofOfAddressType: state.poaDocumentType } : {}),
|
|
102
|
+
// Supporting documents — artefacts held on file. The server validates them
|
|
103
|
+
// against the resolved workflow's request list and drops anything it did
|
|
104
|
+
// not ask for. File names are display only and never ride the wire.
|
|
105
|
+
...(state.supportingDocuments.length > 0
|
|
106
|
+
? {
|
|
107
|
+
supportingDocuments: state.supportingDocuments.map((d) => ({
|
|
108
|
+
type: d.type,
|
|
109
|
+
mediaId: d.mediaId,
|
|
110
|
+
})),
|
|
111
|
+
}
|
|
112
|
+
: {}),
|
|
102
113
|
// The smart address, when the step gathered one. Sent on individual AND
|
|
103
114
|
// business submissions (a KYB flow's pin is the business premises) — the
|
|
104
115
|
// server validates it against the workflow either way.
|
package/src/types/config.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
NfcConfig,
|
|
23
23
|
PhoneVerificationConfig,
|
|
24
24
|
ProofOfAddressConfig,
|
|
25
|
+
SupportingDocumentsConfig,
|
|
25
26
|
QuestionnaireConfig,
|
|
26
27
|
WorkflowCountry,
|
|
27
28
|
MultiIdConfig,
|
|
@@ -57,6 +58,7 @@ export type KYCStep =
|
|
|
57
58
|
| 'applicant-role'
|
|
58
59
|
| 'liveness'
|
|
59
60
|
| 'proof-of-address'
|
|
61
|
+
| 'supporting-documents'
|
|
60
62
|
// The address flow, in order: find it (search) → confirm it (the PIN step,
|
|
61
63
|
// which keeps the original 'address-collection' wire name so older session
|
|
62
64
|
// progress restores cleanly) → show it (entrance photo) → commit it.
|
|
@@ -246,6 +248,14 @@ export interface MyazaKYCConfig<C extends SupportedCountry = SupportedCountry> {
|
|
|
246
248
|
/** Proof-of-address document check, after capture. */
|
|
247
249
|
proofOfAddress?: ProofOfAddressConfig;
|
|
248
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Supporting documents: artefacts the org holds ON FILE, which are NOT the
|
|
253
|
+
* identity evidence the verification is decided on. The org names each one
|
|
254
|
+
* and writes the guidance under it; the result of any check never changes
|
|
255
|
+
* the verification's own status.
|
|
256
|
+
*/
|
|
257
|
+
supportingDocuments?: SupportingDocumentsConfig;
|
|
258
|
+
|
|
249
259
|
/** Address Intelligence: a map-pin smart address (+ optional door photo and
|
|
250
260
|
* directions), corroborated server-side. KYC AND KYB (premises pin). */
|
|
251
261
|
addressCollection?: AddressCollectionConfig;
|
package/src/types/workflow.ts
CHANGED
|
@@ -24,6 +24,45 @@ export type PoaDocumentType =
|
|
|
24
24
|
* server; read here only to word the step. */
|
|
25
25
|
export type PoaNameRule = 'required' | 'optional' | 'off';
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* One requested supporting document. There is no catalogue: the organisation
|
|
29
|
+
* names its own, so the SDK renders the title and guidance the workflow sent.
|
|
30
|
+
*/
|
|
31
|
+
export interface SupportingDocumentRequest {
|
|
32
|
+
/** The organisation's own slug — the wire `type` this upload submits as. */
|
|
33
|
+
key: string;
|
|
34
|
+
/** The title the applicant reads. A document without one is not asked for. */
|
|
35
|
+
label?: string;
|
|
36
|
+
/** Guidance under the slot: which document, and what it has to show. */
|
|
37
|
+
description?: string;
|
|
38
|
+
/** Server-side only: what the document is corroborated against. */
|
|
39
|
+
checks?: Array<'id_number' | 'name'>;
|
|
40
|
+
/**
|
|
41
|
+
* The named values the server will read off it. The SDK reads the NAMES
|
|
42
|
+
* alone, to tell the applicant what the document is being taken for; the
|
|
43
|
+
* reading itself is entirely server-side.
|
|
44
|
+
*/
|
|
45
|
+
fields?: Array<{ key: string; label?: string }>;
|
|
46
|
+
required?: boolean;
|
|
47
|
+
/** Which verified IDs it is asked for, as "CC/idType". Absent = every ID. */
|
|
48
|
+
idTypes?: string[];
|
|
49
|
+
/**
|
|
50
|
+
* Offer it on every flow, whatever ID was verified, so `idTypes` decides only
|
|
51
|
+
* who MUST provide it rather than who sees the slot. Absent = the scope hides
|
|
52
|
+
* it from everybody else, which is the default.
|
|
53
|
+
*/
|
|
54
|
+
alwaysAsk?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SupportingDocumentsConfig {
|
|
58
|
+
enabled?: boolean;
|
|
59
|
+
types?: SupportingDocumentRequest[];
|
|
60
|
+
/** Server-side only. */
|
|
61
|
+
retentionDays?: number;
|
|
62
|
+
/** Server-side only. */
|
|
63
|
+
returnedData?: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
27
66
|
export interface ProofOfAddressConfig {
|
|
28
67
|
/** Adds the Proof of Address step (after capture, before the questionnaire). */
|
|
29
68
|
enabled?: boolean;
|