import { isObject, TDocument, valueOrNA } from '@ballerine/common'; import { ComponentProps, useCallback, useMemo, useState } from 'react'; import { Separator } from '@/common/components/atoms/Separator/Separator'; import { MotionButton } from '@/common/components/molecules/MotionButton/MotionButton'; import { ctw } from '@/common/utils/ctw/ctw'; import { useKycDocumentsAdapter } from '@/domains/documents/hooks/adapters/useKycDocumentsAdapter/useKycDocumentsAdapter'; import { useAmlBlock } from '@/lib/blocks/components/AmlBlock/hooks/useAmlBlock/useAmlBlock'; import { createBlocksTyped } from '@/lib/blocks/create-blocks-typed/create-blocks-typed'; import { motionButtonProps } from '@/lib/blocks/hooks/useAssosciatedCompaniesBlock/useAssociatedCompaniesBlock'; import { useCaseDecision } from '@/pages/Entity/components/Case/hooks/useCaseDecision/useCaseDecision'; import { Badge, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, } from '@ballerine/ui'; import { MotionBadge } from '../../../../../../common/components/molecules/MotionBadge/MotionBadge'; import { capitalize } from '../../../../../../common/utils/capitalize/capitalize'; import { PlayCircle, Send, Pencil } from 'lucide-react'; import { ExtendedJson } from '@/common/types'; import { Select } from '@/common/components/atoms/Select/Select'; import { SelectContent } from '@/common/components/atoms/Select/Select.Content'; import { SelectItem } from '@/common/components/atoms/Select/Select.Item'; import { SelectTrigger } from '@/common/components/atoms/Select/Select.Trigger'; import { SelectValue } from '@/common/components/atoms/Select/Select.Value'; import { systemCreatedIconCell, userCreatedIconCell } from '@/lib/blocks/utils/constants'; const motionBadgeProps = { exit: { opacity: 0, transition: { duration: 0.2 } }, initial: { y: 10, opacity: 0 }, transition: { type: 'spring', bounce: 0.3 }, animate: { y: 0, opacity: 1, transition: { duration: 0.2 } }, } satisfies ComponentProps; const RISK_TO_LABEL = { none: 'None', allowedAge: 'Disallowed age', faceLiveness: 'Face is not lively', documentNotExpired: 'Document expired', geolocationMatch: 'No geolocation match', documentAccepted: 'Document not accepted', faceNotInBlocklist: 'Face is in blocklist', allowedIpLocation: 'Disallowed IP location', faceImageAvailable: 'Face image unavailable', documentRecognised: 'Document not recognized', faceSimilarToPortrait: 'Face not similar to portrait', validDocumentAppearance: 'Invalid document appearance', expectedTrafficBehaviour: 'Unexpected traffic behavior', physicalDocumentPresent: 'Physical document not present', documentBackFullyVisible: 'Document back not fully visible', documentFrontFullyVisible: 'Document front not fully visible', documentBackImageAvailable: 'Document back image unavailable', faceImageQualitySufficient: 'Face image quality insufficient', documentFrontImageAvailable: 'Document front image unavailable', documentImageQualitySufficient: 'Document image quality insufficient', } as const; export const useKycBlock = ({ onInitiateKyc, onInitiateSanctionsScreening, onApprove, onReuploadNeeded, onEdit, documents: passedDocuments, kycSession, aml, entityData, status, isActionsDisabled, isLoadingReuploadNeeded, isLoadingApprove, isInitiateKycDisabled, isInitiateSanctionsScreeningDisabled, isApproveDisabled, isReuploadNeededDisabled, isEditDisabled, reasons, }: { onInitiateKyc: () => void; onInitiateSanctionsScreening: () => void; onApprove: ({ ids }: { ids: string[] }) => () => void; onReuploadNeeded: ({ reason, ids }: { reason: string; ids: string[] }) => () => void; onEdit: () => void; documents: TDocument[]; kycSession: Record< string, { vendor: string; decision: { status: string; riskLabels: string[]; }; result: | { entity: { data: Record; }; aml: { vendor: string; }; documents: TDocument[]; decision: { status: string; riskLabels: string[]; }; } | { vendorResult: { aml: { vendor: string; }; }; kycDocumentDetails?: { expiryDate: string; }; documents: TDocument[]; }; } >; aml: { vendor: string; }; entityData: Record; status: 'revision' | 'approved' | 'rejected' | 'pending' | undefined; isActionsDisabled: boolean; isLoadingReuploadNeeded: boolean; isLoadingApprove: boolean; isInitiateKycDisabled: boolean; isInitiateSanctionsScreeningDisabled: boolean; isApproveDisabled: boolean; isReuploadNeededDisabled: boolean; isEditDisabled: boolean; reasons: string[]; }) => { const noReasons = !reasons?.length; const [reason, setReason] = useState(reasons?.[0] ?? ''); const [comment, setComment] = useState(''); const reasonWithComment = comment ? `${reason} - ${comment}` : reason; const onReasonChange = useCallback((value: string) => setReason(value), [setReason]); const onCommentChange = useCallback((value: string) => setComment(value), [setComment]); const { noAction } = useCaseDecision(); const kycSessionKeys = Object.keys(kycSession ?? {}); const { documents: allDocuments, isLoading: isLoadingDocuments } = useKycDocumentsAdapter({ documents: passedDocuments ?? [], }); const documents = useMemo(() => { return allDocuments?.filter(document => document.type === 'identification_document') ?? []; }, [allDocuments]); const nonIdentificationDocumentsIds = useMemo(() => { return ( documents // 'identification_document' is exclusive to Veriff ?.filter(document => document.type !== 'identification_document') ?.map(document => document.id) ?? [] ); }, [documents]); const riskLabels = kycSessionKeys?.length ? kycSessionKeys.flatMap(key => { if (!kycSession[key]?.result?.decision?.riskLabels?.length) { return 'none'; } return kycSession[key]?.result?.decision?.riskLabels; }) : []; const decision = kycSessionKeys?.length ? kycSessionKeys?.flatMap(key => [ { label: 'Verified With', value: capitalize(kycSession[key]?.vendor ?? ''), }, { label: 'Result', value: kycSession[key]?.result?.decision?.status, props: { className: ctw({ 'text-success': kycSession[key]?.result?.decision?.status === 'approved', 'text-destructive': kycSession[key]?.result?.decision?.status === 'declined', 'font-bold': kycSession[key]?.result?.decision?.status === 'approved' || kycSession[key]?.result?.decision?.status === 'declined', }), }, }, ...(isObject(kycSession[key]) ? [ { label: 'Full report', value: kycSession[key], }, ] : []), ]) ?? [] : []; const amlData = useMemo(() => { if (!Object.keys(aml ?? {}).length && !kycSessionKeys?.length) { return []; } if (aml) { return [aml]; } return kycSessionKeys.map( key => kycSession[key]?.result?.vendorResult?.aml ?? kycSession[key]?.result?.aml, ); }, [kycSession, kycSessionKeys]); const vendor = useMemo(() => { if (aml) { return aml.vendor; } if (!kycSessionKeys?.length) { return; } const amlVendor = kycSessionKeys .map( key => kycSession[key]?.result?.vendorResult?.aml?.vendor ?? kycSession[key]?.result?.aml?.vendor, ) .filter(Boolean); if (!amlVendor.length) { const kycVendor = kycSessionKeys.map(key => kycSession[key]?.vendor).filter(Boolean); return kycVendor.join(', '); } return amlVendor.join(', '); }, [kycSession, kycSessionKeys]); const amlBlock = useAmlBlock({ data: amlData, vendor: vendor ?? '', }); const getDocumentExtractedData = () => { if (!kycSessionKeys?.length) { return []; } return kycSessionKeys .map((key, index, collection) => { const value = Object.entries({ ...kycSession[key]?.result?.entity?.data, ...kycSession[key]?.result?.document, })?.map(([label, value]) => ({ label, value: value as ExtendedJson, })) ?? []; if (!value.length) { return; } return createBlocksTyped() .addBlock() .addCell({ type: 'readOnlyDetails', value, props: { config: { parse: { boolean: true, date: true, datetime: true, isoDate: true, nullish: true, url: true, }, }, }, }) .addCell({ type: 'node', value: index !== collection.length - 1 && , }) .buildFlat(); }) .filter(Boolean); }; const documentExtractedData = getDocumentExtractedData(); const isDisabled = isActionsDisabled || noAction || isLoadingApprove || isLoadingReuploadNeeded; const badgeClassNames = 'text-sm font-bold'; const getDecisionStatusOrAction = ( status: 'revision' | 'approved' | 'rejected' | 'pending' | undefined, ) => { if (status === 'revision') { return createBlocksTyped() .addBlock() .addCell({ type: 'badge', value: 'Pending re-upload', props: { ...motionBadgeProps, variant: 'warning', className: badgeClassNames, }, }) .buildFlat(); } if (status === 'approved') { return createBlocksTyped() .addBlock() .addCell({ type: 'badge', value: 'Approved', props: { ...motionBadgeProps, variant: 'success', className: `${badgeClassNames} bg-success/20`, }, }) .buildFlat(); } if (status === 'rejected') { return createBlocksTyped() .addBlock() .addCell({ type: 'badge', value: 'Rejected', props: { ...motionBadgeProps, variant: 'destructive', className: badgeClassNames, }, }) .buildFlat(); } if (status === 'pending') { return createBlocksTyped() .addBlock() .addCell({ type: 'badge', value: 'Pending ID verification', props: { ...motionBadgeProps, variant: 'warning', className: badgeClassNames, }, }) .buildFlat(); } return createBlocksTyped() .addBlock() .addCell({ type: 'dialog', value: { title: 'Ask to re-upload', trigger: ( Re-upload needed ), description: ( <> By clicking the button below, an email with a link will be sent to the customer, directing them to re-upload the documents you have marked as “re-upload needed”. The case’s status will then change to “Revisions” until the customer will provide the needed documents and fixes. ), content: ( <> {!noReasons && (
)}
{ if (noReasons) { onReasonChange(event.target.value); return; } onCommentChange(event.target.value); }} value={noReasons ? reason : comment} id={noReasons ? `reason` : `comment`} />
), close: ( ), }, }) .addCell({ type: 'dialog', value: { trigger: ( Approve ), title: `Approval confirmation`, description:

Are you sure you want to approve?

, content: null, close: (
), props: { content: { className: 'mb-96', }, title: { className: `text-2xl`, }, }, }, }) .buildFlat(); }; const headerCell = createBlocksTyped() .addBlock() .addCell({ id: 'header', type: 'container', props: { className: 'justify-between items-center pt-6', }, value: createBlocksTyped() .addBlock() .addCell({ type: 'heading', value: (
{`${valueOrNA(entityData?.firstName)} ${valueOrNA( entityData?.lastName, )}`} {entityData?.role && ( {entityData?.role} )}
), props: { className: 'mt-0', }, }) .addCell({ type: 'node', value: ( ), }) .buildFlat(), }) .cellAt(0, 0); return createBlocksTyped() .addBlock() .addCell({ type: 'block', value: createBlocksTyped() .addBlock() .addCell(headerCell) .addCell({ type: 'node', value: , }) .addCell({ id: 'title-with-actions', type: 'container', props: { className: 'mt-2' }, value: createBlocksTyped() .addBlock() .addCell({ type: 'heading', value: 'Identity Verification Results', props: { className: 'mt-0', }, }) .addCell({ type: 'container', props: { className: 'space-x-4' }, value: getDecisionStatusOrAction(status), }) .buildFlat(), }) .addCell({ id: 'kyc-block', type: 'container', value: createBlocksTyped() .addBlock() .addCell({ type: 'container', value: createBlocksTyped() .addBlock() .addCell({ type: 'container', value: createBlocksTyped() .addBlock() .addCell({ type: 'container', value: createBlocksTyped() .addBlock() .addCell(userCreatedIconCell) .addCell({ id: 'header', type: 'heading', value: 'Details', props: { className: 'mt-0', }, }) .buildFlat(), props: { className: 'flex space-x-2 items-center mt-2 ps-3', }, }) .addCell({ type: 'readOnlyDetails', value: Object.entries(entityData ?? {}).map(([label, value]) => ({ label, value, })), props: { config: { parse: { boolean: true, date: true, datetime: true, isoDate: true, nullish: true, url: true, }, }, }, }) .buildFlat(), }) .addCell({ type: 'container', value: documentExtractedData.length ? createBlocksTyped() .addBlock() .addCell({ type: 'container', value: createBlocksTyped() .addBlock() .addCell(systemCreatedIconCell) .addCell({ id: 'header', type: 'heading', value: 'Document Extracted Data', props: { className: 'mt-0', }, }) .buildFlat(), props: { className: 'flex space-x-2 items-center mt-2 ps-3', }, }) .build() .concat(documentExtractedData) .flat(1) : createBlocksTyped() .addBlock() .addCell({ type: 'container', value: createBlocksTyped() .addBlock() .addCell(systemCreatedIconCell) .addCell({ type: 'heading', value: 'Document Extracted Data', props: { className: 'mt-0', }, }) .buildFlat(), props: { className: 'flex space-x-2 items-center mt-2 ps-3', }, }) .addCell({ type: 'paragraph', value: 'Initiate KYC for document extracted data to appear', props: { className: 'py-4 text-slate-500', }, }) .buildFlat(), }) .addCell({ type: 'container', value: decision.length ? createBlocksTyped() .addBlock() .addCell({ type: 'container', value: createBlocksTyped() .addBlock() .addCell(systemCreatedIconCell) .addCell({ id: 'header', type: 'heading', value: 'Document Verification Results', props: { className: 'mt-0', }, }) .buildFlat(), props: { className: 'flex space-x-2 items-center mt-2 ps-3', }, }) .addCell({ type: 'readOnlyDetails', value: decision, props: { config: { sort: { predefinedOrder: ['Result', 'Verified With', 'Full report'], }, parse: { boolean: true, date: true, datetime: true, isoDate: true, nullish: true, url: true, }, }, }, }) .addCell({ type: 'node', value: (

Issues

{riskLabels.map(item => ( {RISK_TO_LABEL[item as keyof typeof RISK_TO_LABEL] ?? item} ))}
), }) .buildFlat() : createBlocksTyped() .addBlock() .addCell({ type: 'container', value: createBlocksTyped() .addBlock() .addCell(systemCreatedIconCell) .addCell({ type: 'heading', value: 'Document Verification Results', props: { className: 'mt-0', }, }) .buildFlat(), props: { className: 'flex space-x-2 items-center mt-2 ps-3', }, }) .addCell({ type: 'paragraph', value: 'Initiate KYC for document verification results to appear', props: { className: 'py-4 text-slate-500', }, }) .buildFlat(), }) .buildFlat(), }) .addCell({ type: 'multiDocuments', value: { isLoading: isLoadingDocuments, data: documents?.flatMap(document => document?.details), }, }) .buildFlat(), }) .addCell({ type: 'node', value: , }) .addCell({ type: 'container', value: amlBlock, }) .buildFlat(), props: { className: ctw({ 'shadow-[0_4px_4px_0_rgba(174,174,174,0.0625)] border-[1px] border-warning': status === 'revision', }), }, }) .build(); };