chore: use monorepo organization

This commit is contained in:
Rafael Taranto 2025-05-12 10:52:54 +01:00
parent deaf7d6ecc
commit a687827f7e
1099 changed files with 8184 additions and 11535 deletions

View file

@ -0,0 +1,297 @@
import IconButton from '@mui/material/IconButton'
import { useMutation, useQuery, gql } from '@apollo/client'
import * as R from 'ramda'
import React, { useState } from 'react'
import { DeleteDialog } from 'src/components/DeleteDialog'
import DataTable from 'src/components/tables/DataTable'
import { Info1, Info3, P } from 'src/components/typography'
import DeleteIcon from 'src/styling/icons/action/delete/enabled.svg?react'
import EditIcon from 'src/styling/icons/action/edit/enabled.svg?react'
import { Button, Link } from 'src/components/buttons'
import { fromNamespace, namespaces, toNamespace } from 'src/utils/config'
import DetailsRow from './DetailsCard'
import Wizard from './Wizard'
import SvgIcon from '@mui/material/SvgIcon'
const inputTypeDisplay = {
numerical: 'Numerical',
text: 'Text',
choiceList: 'Choice list'
}
const constraintTypeDisplay = {
date: 'Date',
none: 'None',
email: 'Email',
length: 'Length',
selectOne: 'Select one',
selectMultiple: 'Select multiple',
spaceSeparation: 'Space separation'
}
const GET_DATA = gql`
query getData {
config
}
`
const SAVE_CONFIG = gql`
mutation Save($config: JSONObject) {
saveConfig(config: $config)
}
`
const ADD_ROW = gql`
mutation insertCustomInfoRequest($customRequest: CustomRequestInput!) {
insertCustomInfoRequest(customRequest: $customRequest) {
id
}
}
`
const EDIT_ROW = gql`
mutation editCustomInfoRequest(
$id: ID!
$customRequest: CustomRequestInput!
) {
editCustomInfoRequest(id: $id, customRequest: $customRequest) {
id
}
}
`
const REMOVE_ROW = gql`
mutation removeCustomInfoRequest($id: ID!) {
removeCustomInfoRequest(id: $id) {
id
}
}
`
const CustomInfoRequests = ({
showWizard,
toggleWizard,
data: customRequests
}) => {
const [toBeDeleted, setToBeDeleted] = useState()
const [toBeEdited, setToBeEdited] = useState()
const [deleteDialog, setDeleteDialog] = useState(false)
const [hasError, setHasError] = useState(false)
const { data: configData, loading: configLoading } = useQuery(GET_DATA)
const [saveConfig] = useMutation(SAVE_CONFIG, {
refetchQueries: () => ['getData'],
onError: () => setHasError(true)
})
const [addEntry] = useMutation(ADD_ROW, {
onError: () => {
console.log('Error while adding custom info request')
setHasError(true)
},
onCompleted: () => {
setHasError(false)
toggleWizard()
},
refetchQueries: () => ['customInfoRequests']
})
const [editEntry] = useMutation(EDIT_ROW, {
onError: () => {
console.log('Error while editing custom info request')
setHasError(true)
},
onCompleted: () => {
setHasError(false)
setToBeEdited(null)
toggleWizard()
},
refetchQueries: () => ['getData', 'customInfoRequests']
})
const [removeEntry] = useMutation(REMOVE_ROW, {
onError: () => {
console.log('Error while removing custom info request')
setHasError(true)
},
onCompleted: () => {
setDeleteDialog(false)
setHasError(false)
},
refetchQueries: () => ['getData', 'customInfoRequests']
})
const config = R.path(['config'])(configData) ?? []
const handleDelete = id => {
removeEntry({
variables: {
id
}
}).then(() => {
const triggersConfig =
(config && fromNamespace(namespaces.TRIGGERS)(config)) ?? []
const cleanConfig = {
overrides: R.reject(
it => it.requirement === id,
triggersConfig.overrides
)
}
const newConfig = toNamespace(namespaces.TRIGGERS)(cleanConfig)
saveConfig({ variables: { config: newConfig } })
})
}
const handleSave = (values, isEditing) => {
if (isEditing) {
return editEntry({
variables: {
id: values.id,
customRequest: R.omit(['id'])(values)
}
})
}
return addEntry({
variables: {
customRequest: {
...values
}
}
})
}
const detailedDeleteMsg = (
<>
<P noMargin>
Deleting this item will result in the triggers using it to be removed,
together with the advanced trigger overrides you defined for this item.
</P>
<P noMargin>
This action is <b>permanent</b>.
</P>
</>
)
return (
!configLoading && (
<>
{customRequests.length > 0 && (
<DataTable
emptyText="No custom info requests so far"
elements={[
{
header: 'Requirement name',
width: 300,
textAlign: 'left',
size: 'sm',
view: it => it.customRequest.name
},
{
header: 'Data entry type',
width: 300,
textAlign: 'left',
size: 'sm',
view: it => inputTypeDisplay[it.customRequest.input.type]
},
{
header: 'Constraints',
width: 300,
textAlign: 'left',
size: 'sm',
view: it =>
constraintTypeDisplay[it.customRequest.input.constraintType]
},
{
header: 'Edit',
width: 100,
textAlign: 'center',
size: 'sm',
view: it => {
return (
<IconButton
onClick={() => {
setToBeEdited(it)
return toggleWizard()
}}>
<SvgIcon>
<EditIcon />
</SvgIcon>
</IconButton>
)
}
},
{
header: 'Delete',
width: 100,
textAlign: 'center',
size: 'sm',
view: it => {
return (
<IconButton
onClick={() => {
setToBeDeleted(it.id)
return setDeleteDialog(true)
}}>
<SvgIcon>
<DeleteIcon />
</SvgIcon>
</IconButton>
)
}
}
]}
data={customRequests}
Details={DetailsRow}
expandable
rowSize="sm"
/>
)}
{!customRequests.length && (
<div className="flex flex-col items-center h-1/2 justify-center">
<Info1 className="m-0 mb-3">
It seems you haven't added any custom information requests yet.
</Info1>
<Info3 className="m-0 mb-3">
Please read our{' '}
<a href="https://support.lamassu.is/hc/en-us/sections/115000817232-Compliance">
<Link>Support Article</Link>
</a>{' '}
on Compliance before adding new information requests.
</Info3>
<Button onClick={() => toggleWizard()}>
Add custom information request
</Button>
</div>
)}
{showWizard && (
<Wizard
hasError={hasError}
onClose={() => {
setToBeEdited(null)
setHasError(false)
toggleWizard()
}}
toBeEdited={toBeEdited}
onSave={(...args) => handleSave(...args)}
existingRequirements={customRequests}
/>
)}
<DeleteDialog
errorMessage={hasError ? 'Failed to delete' : ''}
open={deleteDialog}
onDismissed={() => {
setDeleteDialog(false)
setHasError(false)
}}
item={`custom information request`}
extraMessage={detailedDeleteMsg}
onConfirmed={() => handleDelete(toBeDeleted)}
/>
</>
)
)
}
export default CustomInfoRequests

View file

@ -0,0 +1,84 @@
import React from 'react'
import { Label1, Info2 } from 'src/components/typography'
const DetailsCard = ({ it }) => {
const customRequest = it.customRequest
const getScreen2Data = () => {
const label1Display =
customRequest.input.constraintType === 'spaceSeparation'
? 'First word label'
: 'Text entry label'
switch (customRequest.input.type) {
case 'text':
return (
<>
<div className="w-1/2 mb-4 mr-12">
<Info2>{label1Display}</Info2>
<Label1>{customRequest.input.label1}</Label1>
</div>
{customRequest.input.constraintType === 'spaceSeparation' && (
<div className="w-1/2 mb-4 mr-12">
<Info2>Second word label</Info2>
<Label1>{customRequest.input.label2}</Label1>
</div>
)}
</>
)
default:
return (
<>
<div className="w-1/2 mb-4 mr-12">
<Info2>Screen 2 input title</Info2>
<Label1>{customRequest.screen2.title}</Label1>
</div>
<div className="w-1/2 mb-4 mr-12">
<Info2>Screen 2 input description</Info2>
<Label1>{customRequest.screen2.text}</Label1>
</div>
</>
)
}
}
const getInputData = () => {
return (
<>
{customRequest.input.choiceList && (
<>
<Info2>Choices</Info2>
{customRequest.input.choiceList.map((choice, idx) => {
return <Label1 key={idx}>{choice}</Label1>
})}
</>
)}
{customRequest.input.numDigits && (
<>
<Info2>Number of digits</Info2>
<Label1>{customRequest.input.numDigits}</Label1>
</>
)}
</>
)
}
return (
<div>
<div className="flex mt-5">
<div className="w-1/2 mb-4 mr-12">
<Info2>Screen 1 title</Info2>
<Label1>{customRequest.screen1.title}</Label1>
</div>
<div className="flex w-1/2 mb-4 mr-12">{getScreen2Data()}</div>
</div>
<div className="flex mb-5">
<div className="w-1/2 mb-4 mr-12">
<Info2>Screen 1 text</Info2>
<Label1>{customRequest.screen1.text}</Label1>
</div>
<div className="w-1/2 mb-4 mr-12">{getInputData()}</div>
</div>
</div>
)
}
export default DetailsCard

View file

@ -0,0 +1,76 @@
import { Field } from 'formik'
import React from 'react'
import ToggleButtonGroup from 'src/components/inputs/formik/ToggleButtonGroup'
import { H4 } from 'src/components/typography'
import Keyboard from 'src/styling/icons/compliance/keyboard.svg?react'
import Keypad from 'src/styling/icons/compliance/keypad.svg?react'
import List from 'src/styling/icons/compliance/list.svg?react'
import * as Yup from 'yup'
import { zircon } from 'src/styling/variables'
const MakeIcon = IconSvg => (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: zircon,
borderRadius: 4,
maxWidth: 104,
maxHeight: 64,
minWidth: 104,
minHeight: 64
}}>
<IconSvg style={{ maxWidth: 80 }} />
</div>
)
const ChooseType = () => {
const options = [
{
value: 'numerical',
title: 'Numerical entry',
description:
'User will enter information with a keypad. Good for dates, ID numbers, etc.',
icon: () => MakeIcon(Keypad)
},
{
value: 'text',
title: 'Text entry',
description:
'User will entry information with a keyboard. Good for names, email, address, etc.',
icon: () => MakeIcon(Keyboard)
},
{
value: 'choiceList',
title: 'Choice list',
description: 'Gives user multiple options to choose from.',
icon: () => MakeIcon(List)
}
]
return (
<>
<H4>Choose the type of data entry</H4>
<Field
name="inputType"
component={ToggleButtonGroup}
orientation="vertical"
exclusive
options={options}
/>
</>
)
}
const validationSchema = Yup.object().shape({
inputType: Yup.string().label('Input type').required()
})
const defaultValues = {
inputType: ''
}
export default ChooseType
export { validationSchema, defaultValues }

View file

@ -0,0 +1,48 @@
import { Field } from 'formik'
import * as R from 'ramda'
import React from 'react'
import TextInputFormik from 'src/components/inputs/formik/TextInput'
import { H4, P } from 'src/components/typography'
import * as Yup from 'yup'
const NameOfRequirement = () => {
return (
<>
<H4>Name of the requirement</H4> {/* TODO Add ? icon */}
<P>
The name of the requirement will only be visible to you on the dashboard
on the requirement list, as well as on the custom information request
list. The user won't see this name. Make sure to make it distinguishable
and short.
</P>
<Field
component={TextInputFormik}
label="Requirement name"
name="requirementName"
fullWidth
/>
</>
)
}
const validationSchema = existingRequirements =>
Yup.object().shape({
requirementName: Yup.string()
.required('Name is required')
.test(
'unique-name',
'A custom information requirement with that name already exists',
(value, _context) =>
!R.includes(
R.toLower(R.defaultTo('', value)),
R.map(it => R.toLower(it.customRequest.name), existingRequirements)
)
)
})
const defaultValues = {
requirementName: ''
}
export default NameOfRequirement
export { validationSchema, defaultValues }

View file

@ -0,0 +1,44 @@
import { Field } from 'formik'
import React from 'react'
import TextInputFormik from 'src/components/inputs/formik/TextInput'
import { H4, P } from 'src/components/typography'
import * as Yup from 'yup'
const Screen1Information = () => {
return (
<>
<H4>Screen 1 Information</H4> {/* TODO Add ? icon */}
<P>
On screen 1 you will request the user if he agrees on providing this
information, or if he wishes to terminate the transaction instead.
</P>
<Field
component={TextInputFormik}
label="Screen title"
name="screen1Title"
fullWidth
/>
<Field
component={TextInputFormik}
label="Screen text"
name="screen1Text"
multiline
fullWidth
rows={5}
/>
</>
)
}
const validationSchema = Yup.object().shape({
screen1Title: Yup.string().label('Screen title').required(),
screen1Text: Yup.string().label('Screen text').required()
})
const defaultValues = {
screen1Title: '',
screen1Text: ''
}
export default Screen1Information
export { validationSchema, defaultValues }

View file

@ -0,0 +1,42 @@
import { Field } from 'formik'
import React from 'react'
import TextInputFormik from 'src/components/inputs/formik/TextInput'
import { H4, P } from 'src/components/typography'
import * as Yup from 'yup'
const ScreenInformation = () => {
return (
<>
<H4>Screen 2 Information</H4> {/* TODO Add ? icon */}
<P>
If the user agrees, on screen 2 is where the user will enter the custom
information.
</P>
<Field
component={TextInputFormik}
label="Screen 2 input title"
name="screen2Title"
fullWidth
/>
<Field
component={TextInputFormik}
label="Screen 2 input description"
name="screen2Text"
fullWidth
/>
</>
)
}
const validationSchema = Yup.object().shape({
screen2Title: Yup.string().label('Screen title').required(),
screen2Text: Yup.string().label('Screen text').required()
})
const defaultValues = {
screen2Title: '',
screen2Text: ''
}
export default ScreenInformation
export { validationSchema, defaultValues }

View file

@ -0,0 +1,96 @@
import classnames from 'classnames'
import { Field, useFormikContext, FieldArray } from 'formik'
import * as R from 'ramda'
import React, { useEffect, useRef } from 'react'
import Button from 'src/components/buttons/ActionButton'
import RadioGroup from 'src/components/inputs/formik/RadioGroup'
import TextInput from 'src/components/inputs/formik/TextInput'
import { H4 } from 'src/components/typography'
import AddIconInverse from 'src/styling/icons/button/add/white.svg?react'
import AddIcon from 'src/styling/icons/button/add/zodiac.svg?react'
const nonEmptyStr = obj => obj.text && obj.text.length
const options = [
{ display: 'Select just one', code: 'selectOne' },
{ display: 'Select multiple', code: 'selectMultiple' }
]
const ChoiceList = () => {
const context = useFormikContext()
const choiceListRef = useRef(null)
const listChoices = R.path(['values', 'listChoices'])(context) ?? []
const choiceListError = R.path(['errors', 'listChoices'])(context) ?? false
const showErrorColor = {
'mb-0': true,
'text-tomato':
!R.path(['values', 'constraintType'])(context) &&
R.path(['errors', 'constraintType'])(context)
}
const hasError = choice => {
return (
choiceListError &&
R.filter(nonEmptyStr)(listChoices).length < 2 &&
choice.text.length === 0
)
}
useEffect(() => {
scrollToBottom()
}, [listChoices.length])
const scrollToBottom = () => {
choiceListRef.current?.scrollIntoView()
}
return (
<>
<H4 className={classnames(showErrorColor)}>Choice list constraints</H4>
<Field
component={RadioGroup}
options={options}
className="flex-col"
name="constraintType"
/>
<FieldArray name="listChoices">
{({ push }) => {
return (
<div className="flex flex-col">
<H4 className="mb-0">Choices</H4>
<div className="flex flex-col max-h-60">
{listChoices.map((choice, idx) => {
return (
<div ref={choiceListRef} key={idx}>
<Field
className="w-105"
error={hasError(choice)}
component={TextInput}
name={`listChoices[${idx}].text`}
label={`Choice ${idx + 1}`}
/>
</div>
)
})}
</div>
<Button
Icon={AddIcon}
color="primary"
InverseIcon={AddIconInverse}
className="w-30 h-7 mt-7"
onClick={e => {
e.preventDefault()
return push({ text: '' })
}}>
Add choice
</Button>
</div>
)
}}
</FieldArray>
</>
)
}
export default ChoiceList

View file

@ -0,0 +1,56 @@
import classnames from 'classnames'
import { Field, useFormikContext } from 'formik'
import * as R from 'ramda'
import React from 'react'
import NumberInput from 'src/components/inputs/formik/NumberInput'
import RadioGroup from 'src/components/inputs/formik/RadioGroup'
import { TL1, H4 } from 'src/components/typography'
const options = [
{ display: 'None', code: 'none' },
{ display: 'Date', code: 'date' },
{ display: 'Length', code: 'length' }
]
const NumericalEntry = () => {
const context = useFormikContext()
const isLength =
(R.path(['values', 'constraintType'])(useFormikContext()) ?? null) ===
'length'
const showErrorColor = {
'mb-0': true,
'text-tomat':
!R.path(['values', 'constraintType'])(context) &&
R.path(['errors', 'constraintType'])(context)
}
return (
<>
<H4 className={classnames(showErrorColor)}>
Numerical entry constraints
</H4>
<Field
className="flex-row"
component={RadioGroup}
options={options}
name="constraintType"
/>
{isLength && (
<div className="flex mt-6 max-w-29">
<Field
component={NumberInput}
name={'inputLength'}
label={'Length'}
decimalPlaces={0}
allowNegative={false}
/>
<TL1 className="ml-2 mt-6">digits</TL1>
</div>
)}
</>
)
}
export default NumericalEntry

View file

@ -0,0 +1,73 @@
import classnames from 'classnames'
import { Field, useFormikContext } from 'formik'
import * as R from 'ramda'
import React from 'react'
import RadioGroup from 'src/components/inputs/formik/RadioGroup'
import TextInput from 'src/components/inputs/formik/TextInput'
import { H4 } from 'src/components/typography'
const options = [
{ display: 'None', code: 'none' },
{ display: 'Email', code: 'email' },
{
display: 'Space separation',
subtitle: '(e.g. first and last name)',
code: 'spaceSeparation'
}
]
const TextEntry = () => {
const context = useFormikContext()
const showErrorColor = {
'mt-0': true,
'text-tomato':
!R.path(['values', 'constraintType'])(context) &&
R.path(['errors', 'constraintType'])(context)
}
const getLabelInputs = () => {
switch (context.values.constraintType) {
case 'spaceSeparation':
return (
<div className="flex">
<Field
className="w-50 mr-2"
component={TextInput}
name={'inputLabel1'}
label={'First word label'}
/>
<Field
className="w-50 mr-2"
component={TextInput}
name={'inputLabel2'}
label={'Second word label'}
/>
</div>
)
default:
return (
<Field
className="w-50 mr-2"
component={TextInput}
name={'inputLabel1'}
label={'Text entry label'}
/>
)
}
}
return (
<>
<H4 className={classnames(showErrorColor)}>Text entry constraints</H4>
<Field
className="flex-row"
component={RadioGroup}
options={options}
name="constraintType"
/>
{getLabelInputs()}
</>
)
}
export default TextEntry

View file

@ -0,0 +1,78 @@
import { useFormikContext } from 'formik'
import * as R from 'ramda'
import React from 'react'
import * as Yup from 'yup'
import ChoiceList from './ChoiceList'
import NumericalEntry from './NumericalEntry'
import TextEntry from './TextEntry'
const nonEmptyStr = obj => obj.text && obj.text.length
const getForm = inputType => {
switch (inputType) {
case 'numerical':
return NumericalEntry
case 'text':
return TextEntry
case 'choiceList':
return ChoiceList
default:
return NumericalEntry
}
}
const TypeFields = () => {
const inputType = R.path(['values', 'inputType'])(useFormikContext()) ?? null
const Component = getForm(inputType)
return inputType && <Component />
}
const defaultValues = {
constraintType: '',
inputLength: '',
inputLabel1: '',
inputLabel2: '',
listChoices: [{ text: '' }, { text: '' }]
}
const validationSchema = Yup.lazy(values => {
switch (values.inputType) {
case 'numerical':
return Yup.object({
constraintType: Yup.string().label('Constraint type').required(),
inputLength: Yup.number().when('constraintType', {
is: 'length',
then: schema =>
schema.min(0).required('The number of digits is required'),
otherwise: schema => schema.notRequired()
})
})
case 'text':
return Yup.object({
constraintType: Yup.string().label('Constraint type').required(),
inputLabel1: Yup.string().label('Text entry label').required(),
inputLabel2: Yup.string().when('constraintType', {
is: 'spaceSeparation',
then: schema => schema.label('Second word label').required(),
otherwise: schema => schema.notRequired()
})
})
case 'choiceList':
return Yup.object({
constraintType: Yup.string().label('Constraint type').required(),
listChoices: Yup.array().test(
'has-2-or-more',
'Choice list needs to have two or more non empty fields',
(values, ctx) => {
return R.filter(nonEmptyStr)(values).length > 1
}
)
})
default:
return Yup.mixed().notRequired()
}
})
export default TypeFields
export { defaultValues, validationSchema }

View file

@ -0,0 +1,223 @@
import { Form, Formik } from 'formik'
import * as R from 'ramda'
import React, { useState } from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal'
import Stepper from 'src/components/Stepper'
import { Button } from 'src/components/buttons'
import ChooseType, {
validationSchema as chooseTypeSchema,
defaultValues as chooseTypeDefaults
} from './Forms/ChooseType'
import NameOfRequirement, {
validationSchema as nameOfReqSchema,
defaultValues as nameOfReqDefaults
} from './Forms/NameOfRequirement'
import Screen1Information, {
validationSchema as screen1InfoSchema,
defaultValues as screen1InfoDefaults
} from './Forms/Screen1Information'
import Screen2Information, {
validationSchema as screen2InfoSchema,
defaultValues as screen2InfoDefaults
} from './Forms/Screen2Information'
import TypeFields, {
defaultValues as typeFieldsDefaults,
validationSchema as typeFieldsValidationSchema
} from './Forms/TypeFields'
import WizardSplash from './WizardSplash'
const LAST_STEP = 5
const getStep = (step, existingRequirements) =>
[
{
validationSchema: nameOfReqSchema(existingRequirements),
Component: NameOfRequirement
},
{
validationSchema: screen1InfoSchema,
Component: Screen1Information
},
{ validationSchema: chooseTypeSchema, Component: ChooseType },
{
validationSchema: screen2InfoSchema,
Component: Screen2Information
},
{
validationSchema: typeFieldsValidationSchema,
Component: TypeFields
}
][step - 1]
const nonEmptyStr = obj => obj.text && obj.text.length
const formatValues = (values, isEditing) => {
const isChoiceList = values.inputType === 'choiceList'
const choices = isChoiceList
? isEditing
? R.path(['listChoices'])(values)
: R.map(o => o.text)(R.filter(nonEmptyStr)(values.listChoices) ?? [])
: []
const hasInputLength = values.constraintType === 'length'
const inputLength = hasInputLength ? values.inputLength : ''
let resObj = {
name: values.requirementName,
screen1: {
text: values.screen1Text,
title: values.screen1Title
},
screen2: {
title: values.screen2Title,
text: values.screen2Text
},
input: {
type: values.inputType,
constraintType: values.constraintType
}
}
if (isChoiceList) {
resObj = R.assocPath(['input', 'choiceList'], choices, resObj)
}
if (hasInputLength) {
resObj = R.assocPath(['input', 'numDigits'], inputLength, resObj)
}
if (values.inputLabel1) {
resObj = R.assocPath(['input', 'label1'], values.inputLabel1, resObj)
}
if (values.inputLabel2) {
resObj = R.assocPath(['input', 'label2'], values.inputLabel2, resObj)
}
if (isEditing) {
resObj = R.assocPath(['id'], values.id, resObj)
}
return resObj
}
const makeEditingValues = ({ customRequest, id }) => {
return {
id,
requirementName: customRequest.name,
screen1Title: customRequest.screen1.title,
screen1Text: customRequest.screen1.text,
screen2Title: customRequest.screen2.title,
screen2Text: customRequest.screen2.text,
inputType: customRequest.input.type,
inputLabel1: customRequest.input.label1,
inputLabel2: customRequest.input.label2,
listChoices: customRequest.input.choiceList,
constraintType: customRequest.input.constraintType,
inputLength: customRequest.input.numDigits
}
}
const chooseNotNull = (a, b) => (R.isNil(b) ? a : b)
const Wizard = ({
onClose,
error = false,
toBeEdited,
onSave,
hasError,
existingRequirements
}) => {
const isEditing = !R.isNil(toBeEdited)
const [step, setStep] = useState(isEditing ? 1 : 0)
const defaultValues = {
...nameOfReqDefaults,
...screen1InfoDefaults,
...screen2InfoDefaults,
...chooseTypeDefaults,
...typeFieldsDefaults
}
// If we're editing, filter out the requirement being edited so that validation schemas don't enter in circular conflicts
existingRequirements = isEditing
? R.filter(it => it.id !== toBeEdited.id, existingRequirements)
: existingRequirements
const stepOptions = getStep(step, existingRequirements)
const isLastStep = step === LAST_STEP
const onContinue = (values, actions) => {
const showScreen2 =
values.inputType === 'numerical' || values.inputType === 'choiceList'
if (isEditing && step === 2) {
return showScreen2
? setStep(4)
: onSave(formatValues(values, isEditing), isEditing)
}
if (isEditing && step === 4) {
return onSave(formatValues(values, isEditing), isEditing)
}
if (step === 3) {
return showScreen2 ? setStep(step + 1) : setStep(step + 2)
}
if (!isLastStep) {
return setStep(step + 1)
}
return onSave(formatValues(values, isEditing), isEditing)
}
const editingValues = isEditing ? makeEditingValues(toBeEdited) : {}
const initialValues = R.mergeWith(chooseNotNull, defaultValues, editingValues)
const wizardTitle = isEditing
? 'Editing custom requirement'
: 'New custom requirement'
return (
<Modal
title={step > 0 ? wizardTitle : ''}
handleClose={onClose}
width={520}
height={620}
open={true}>
{step > 0 && (
<Stepper
className="mt-4 mb-4 mx-0"
steps={LAST_STEP}
currentStep={step}
/>
)}
{step === 0 && !isEditing && <WizardSplash onContinue={onContinue} />}
{step > 0 && (
<Formik
validateOnBlur={false}
validateOnChange={false}
enableReinitialize={true}
onSubmit={onContinue}
initialValues={initialValues}
validationSchema={stepOptions.validationSchema}>
{({ errors }) => (
<Form
className="h-full flex flex-col"
id={'custom-requirement-form'}>
<stepOptions.Component />
<div className="flex flex-row mt-auto mx-0 mb-4">
{(hasError || !R.isEmpty(errors)) && (
<ErrorMessage>
{R.head(R.values(errors)) ?? `Failed to save`}
</ErrorMessage>
)}
<Button className="ml-auto" type="submit">
{isLastStep ? 'Save' : 'Next'}
</Button>
</div>
</Form>
)}
</Formik>
)}
</Modal>
)
}
export default Wizard

View file

@ -0,0 +1,29 @@
import React from 'react'
import { H1, P } from 'src/components/typography'
import CustomReqLogo from 'src/styling/icons/compliance/custom-requirement.svg?react'
import { Button } from 'src/components/buttons'
const WizardSplash = ({ onContinue }) => {
return (
<div className="flex flex-col items-center py-0 px-10 flex-1">
<CustomReqLogo className="max-h-37 max-w-50" />
<H1 className="mt-6 mb-8 mx-0">Custom information request</H1>
<P className="m-0">
A custom information request allows you to have an extra option to ask
specific information about your customers when adding a trigger that
isn't an option on the default requirements list.
</P>
<P>
Note that adding a custom information request isn't the same as adding
triggers. You will still need to add a trigger with the new requirement
to get this information from your customers.
</P>
<Button className="mt-auto mb-14" onClick={onContinue}>
Get started
</Button>
</div>
)
}
export default WizardSplash

View file

@ -0,0 +1,2 @@
import CustomInfoRequests from './CustomInfoRequests'
export default CustomInfoRequests

View file

@ -0,0 +1,93 @@
import { useMutation, gql } from '@apollo/client'
import * as R from 'ramda'
import React, { useState } from 'react'
import { H2 } from 'src/components/typography'
import { v4 as uuidv4 } from 'uuid'
import { Button } from 'src/components/buttons'
import { Table as EditableTable } from 'src/components/editableTable'
import { fromNamespace, namespaces } from 'src/utils/config'
import Wizard from './Wizard'
import { Schema, getElements, sortBy, toServer } from './helper'
const SAVE_CONFIG = gql`
mutation Save($config: JSONObject) {
saveConfig(config: $config)
}
`
const TriggerView = ({
triggers,
showWizard,
config,
toggleWizard,
addNewTriger,
emailAuth,
complianceServices,
customInfoRequests
}) => {
const currency = R.path(['fiatCurrency'])(
fromNamespace(namespaces.LOCALE)(config)
)
const [error, setError] = useState(null)
const [saveConfig] = useMutation(SAVE_CONFIG, {
onCompleted: () => toggleWizard(true),
refetchQueries: () => ['getData'],
onError: error => setError(error)
})
const save = config => {
setError(null)
return saveConfig({
variables: { config: { triggers: toServer(config.triggers) } }
})
}
const add = rawConfig => {
const toSave = R.concat([
{ id: uuidv4(), direction: 'both', ...rawConfig }
])(triggers)
return saveConfig({ variables: { config: { triggers: toServer(toSave) } } })
}
return (
<>
<EditableTable
data={triggers}
name="triggers"
enableEdit
sortBy={sortBy}
groupBy="triggerType"
enableDelete
error={error?.message}
save={save}
validationSchema={Schema}
elements={getElements(currency, customInfoRequests)}
/>
{showWizard && (
<Wizard
currency={currency}
error={error?.message}
save={add}
onClose={() => toggleWizard(true)}
customInfoRequests={customInfoRequests}
complianceServices={complianceServices}
emailAuth={emailAuth}
triggers={triggers}
/>
)}
{R.isEmpty(triggers) && (
<div className="flex items-center flex-col mt-30">
<H2>
It seems there are no active compliance triggers on your network
</H2>
<Button onClick={addNewTriger}>Add first trigger</Button>
</div>
)}
</>
)
}
export default TriggerView

View file

@ -0,0 +1,261 @@
import { useQuery, useMutation, gql } from '@apollo/client'
import Switch from '@mui/material/Switch'
import classnames from 'classnames'
import * as R from 'ramda'
import React, { useState } from 'react'
import Modal from 'src/components/Modal'
import { HelpTooltip } from 'src/components/Tooltip'
import TitleSection from 'src/components/layout/TitleSection'
import { P, Label2 } from 'src/components/typography'
import FormRenderer from 'src/pages/Services/FormRenderer'
import ReverseCustomInfoIcon from 'src/styling/icons/circle buttons/filter/white.svg?react'
import CustomInfoIcon from 'src/styling/icons/circle buttons/filter/zodiac.svg?react'
import ReverseSettingsIcon from 'src/styling/icons/circle buttons/settings/white.svg?react'
import SettingsIcon from 'src/styling/icons/circle buttons/settings/zodiac.svg?react'
import { Link, SupportLinkButton } from 'src/components/buttons'
import twilioSchema from 'src/pages/Services/schemas/twilio'
import { fromNamespace, toNamespace } from 'src/utils/config'
import CustomInfoRequests from './CustomInfoRequests'
import TriggerView from './TriggerView'
import AdvancedTriggers from './components/AdvancedTriggers'
import { fromServer } from './helper'
const SAVE_ACCOUNT = gql`
mutation Save($accounts: JSONObject) {
saveAccounts(accounts: $accounts)
}
`
const SAVE_CONFIG = gql`
mutation Save($config: JSONObject) {
saveConfig(config: $config)
}
`
const GET_CONFIG = gql`
query getData {
config
accounts
accountsConfig {
code
display
class
cryptos
}
}
`
const GET_CUSTOM_REQUESTS = gql`
query customInfoRequests {
customInfoRequests {
id
customRequest
enabled
}
}
`
const Triggers = () => {
const [wizardType, setWizard] = useState(false)
const { data, loading: configLoading, refetch } = useQuery(GET_CONFIG)
const { data: customInfoReqData, loading: customInfoLoading } =
useQuery(GET_CUSTOM_REQUESTS)
const [error, setError] = useState(null)
const [subMenu, setSubMenu] = useState(false)
const [twilioSetupPopup, setTwilioSetupPopup] = useState(false)
const enabledCustomInfoRequests = R.pipe(
R.path(['customInfoRequests']),
R.defaultTo([]),
R.filter(R.propEq('enabled', true))
)(customInfoReqData)
const emailAuth =
data?.config?.triggersConfig_customerAuthentication === 'EMAIL'
const complianceServices = R.filter(R.propEq('class', 'compliance'))(
data?.accountsConfig || []
)
const triggers = fromServer(data?.config?.triggers ?? [])
const complianceConfig =
data?.config && fromNamespace('compliance')(data.config)
const rejectAddressReuse = complianceConfig?.rejectAddressReuse ?? false
const [saveConfig] = useMutation(SAVE_CONFIG, {
onCompleted: () => setWizard(false),
refetchQueries: () => ['getData'],
onError: error => setError(error)
})
const [saveAccount] = useMutation(SAVE_ACCOUNT, {
onCompleted: () => {
setTwilioSetupPopup(false)
toggleWizard('newTrigger')()
},
refetchQueries: () => ['getData'],
onError: error => setError(error)
})
const addressReuseSave = rawConfig => {
const config = toNamespace('compliance')(rawConfig)
return saveConfig({ variables: { config } })
}
const titleSectionWidth = {
'w-230': !subMenu === 'customInfoRequests'
}
const setBlur = shouldBlur => {
return shouldBlur
? document.querySelector('#root').classList.add('root-blur')
: document.querySelector('#root').classList.remove('root-blur')
}
const toggleWizard = wizardName => forceDisable => {
if (wizardType === wizardName || forceDisable) {
setBlur(false)
return setWizard(null)
}
setBlur(true)
return setWizard(wizardName)
}
const loading = configLoading || customInfoLoading
const twilioSave = it => {
setError(null)
return saveAccount({
variables: { accounts: { twilio: it } }
})
}
const addNewTriger = () => {
if (!R.has('twilio', data?.accounts || {})) setTwilioSetupPopup(true)
else toggleWizard('newTrigger')()
}
return (
<>
<TitleSection
title="Compliance triggers"
buttons={[
{
text: 'Advanced settings',
icon: SettingsIcon,
inverseIcon: ReverseSettingsIcon,
forceDisable: !(subMenu === 'advancedSettings'),
toggle: show => {
refetch()
setSubMenu(show ? 'advancedSettings' : false)
}
},
{
text: 'Custom info requests',
icon: CustomInfoIcon,
inverseIcon: ReverseCustomInfoIcon,
forceDisable: !(subMenu === 'customInfoRequests'),
toggle: show => {
refetch()
setSubMenu(show ? 'customInfoRequests' : false)
}
}
]}
className={classnames(titleSectionWidth)}>
{!subMenu && (
<div className="flex items-center">
<div className="flex items-center justify-end -mr-1">
<P>Reject reused addresses</P>
<Switch
checked={rejectAddressReuse}
onChange={event => {
addressReuseSave({ rejectAddressReuse: event.target.checked })
}}
value={rejectAddressReuse}
/>
<Label2 className="m-3 w-6">
{rejectAddressReuse ? 'On' : 'Off'}
</Label2>
<HelpTooltip width={304}>
<P>
For details about rejecting address reuse, please read the
relevant knowledgebase article:
</P>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/360033622211-Reject-Address-Reuse"
label="Reject Address Reuse"
/>
</HelpTooltip>
</div>
</div>
)}
{subMenu === 'customInfoRequests' &&
!R.isEmpty(enabledCustomInfoRequests) && (
<div className="flex justify-end">
<Link
color="primary"
onClick={() => toggleWizard('newCustomRequest')()}>
+ Add new custom info request
</Link>
</div>
)}
{!loading && !subMenu && !R.isEmpty(triggers) && (
<div className="flex justify-end">
<Link color="primary" onClick={addNewTriger}>
+ Add new trigger
</Link>
</div>
)}
</TitleSection>
{!loading && subMenu === 'customInfoRequests' && (
<CustomInfoRequests
data={enabledCustomInfoRequests}
showWizard={wizardType === 'newCustomRequest'}
toggleWizard={toggleWizard('newCustomRequest')}
/>
)}
{!loading && !subMenu && (
<TriggerView
triggers={triggers}
showWizard={wizardType === 'newTrigger'}
config={data?.config ?? {}}
toggleWizard={toggleWizard('newTrigger')}
addNewTriger={addNewTriger}
emailAuth={emailAuth}
complianceServices={complianceServices}
customInfoRequests={enabledCustomInfoRequests}
/>
)}
{!loading && subMenu === 'advancedSettings' && (
<AdvancedTriggers
error={error}
save={saveConfig}
data={data}></AdvancedTriggers>
)}
{twilioSetupPopup && (
<Modal
title={`Configure SMS`}
width={478}
handleClose={() => setTwilioSetupPopup(false)}
open={true}>
<P>
In order for compliance triggers to work, you'll first need to
configure Twilio.
</P>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/115001203951-Twilio-for-SMS"
label="Lamassu Support Article"
/>
<FormRenderer
save={twilioSave}
elements={twilioSchema.elements}
validationSchema={twilioSchema.getValidationSchema}
/>
</Modal>
)}
</>
)
}
export default Triggers

View file

@ -0,0 +1,315 @@
import { Form, Formik, useFormikContext } from 'formik'
import * as R from 'ramda'
import React, { useState, Fragment, useEffect } from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal'
import Stepper from 'src/components/Stepper'
import { H5, Info3 } from 'src/components/typography'
import { Button } from 'src/components/buttons'
import { singularOrPlural } from 'src/utils/string'
import { type, requirements } from './helper'
const LAST_STEP = 2
const getStep = (
{ step, config },
currency,
customInfoRequests,
complianceServices,
emailAuth,
triggers
) => {
switch (step) {
// case 1:
// return txDirection
case 1:
return type(currency)
case 2:
return requirements(
config,
triggers,
customInfoRequests,
complianceServices,
emailAuth
)
default:
return Fragment
}
}
const getText = (step, config, currency) => {
switch (step) {
// case 1:
// return `In ${getDirectionText(config)} transactions`
case 1:
return <>If the user {getTypeText(config, currency)}</>
case 2:
return <>the user will be {getRequirementText(config)}.</>
default:
return <></>
}
}
const orUnderline = value => {
const blankSpaceEl = (
<span className="py-0 px-7 my-0 mx-1 border-b-1 border-b-comet inline-block"></span>
)
return R.isEmpty(value) || R.isNil(value) ? blankSpaceEl : value
}
// const getDirectionText = config => {
// switch (config.direction) {
// case 'both':
// return 'both cash-in and cash-out'
// case 'cashIn':
// return 'cash-in'
// case 'cashOut':
// return 'cash-out'
// default:
// return orUnderline(null)
// }
// }
const getTypeText = (config, currency) => {
switch (config.triggerType) {
case 'txAmount':
return (
<>
makes a single transaction over{' '}
{orUnderline(config.threshold.threshold)} {currency}
</>
)
case 'txVolume':
return (
<>
makes more than {orUnderline(config.threshold.threshold)} {currency}{' '}
worth of transactions within{' '}
{orUnderline(config.threshold.thresholdDays)}{' '}
{singularOrPlural(config.threshold.thresholdDays, 'day', 'days')}
</>
)
case 'txVelocity':
return (
<>
makes more than {orUnderline(config.threshold.threshold)}{' '}
{singularOrPlural(
config.threshold.threshold,
'transaction',
'transactions'
)}{' '}
in {orUnderline(config.threshold.thresholdDays)}{' '}
{singularOrPlural(config.threshold.thresholdDays, 'day', 'days')}
</>
)
case 'consecutiveDays':
return (
<>
at least one transaction every day for{' '}
{orUnderline(config.threshold.thresholdDays)}{' '}
{singularOrPlural(config.threshold.thresholdDays, 'day', 'days')}
</>
)
default:
return <></>
}
}
const getRequirementText = config => {
switch (config.requirement?.requirement) {
case 'email':
return <>asked to enter code provided through email verification</>
case 'sms':
return <>asked to enter code provided through SMS verification</>
case 'idCardPhoto':
return <>asked to scan a ID with photo</>
case 'idCardData':
return <>asked to scan a ID</>
case 'facephoto':
return <>asked to have a photo taken</>
case 'usSsn':
return <>asked to input his social security number</>
case 'sanctions':
return <>matched against the OFAC sanctions list</>
case 'superuser':
return <></>
case 'suspend':
return (
<>
suspended for {orUnderline(config.requirement.suspensionDays)}{' '}
{singularOrPlural(config.requirement.suspensionDays, 'day', 'days')}
</>
)
case 'block':
return <>blocked</>
case 'custom':
return <>asked to fulfill a custom requirement</>
case 'external':
return <>redirected to an external verification process</>
default:
return orUnderline(null)
}
}
const InfoPanel = ({ step, config = {}, liveValues = {}, currency }) => {
const oldText = R.range(1, step).map((it, idx) => (
<React.Fragment key={idx}>{getText(it, config, currency)}</React.Fragment>
))
const newText = getText(step, liveValues, currency)
const isLastStep = step === LAST_STEP
return (
<>
<H5 className="my-5 mx-0">Trigger overview so far</H5>
<Info3 noMargin>
{oldText}
{step !== 1 && ', '}
<span className="text-comet">{newText}</span>
{!isLastStep && '...'}
</Info3>
</>
)
}
const GetValues = ({ setValues }) => {
const { values } = useFormikContext()
useEffect(() => {
setValues && values && setValues(values)
}, [setValues, values])
return null
}
const Wizard = ({
onClose,
save,
error,
currency,
customInfoRequests,
complianceServices,
emailAuth,
triggers
}) => {
const [liveValues, setLiveValues] = useState({})
const [{ step, config }, setState] = useState({
step: 1
})
const isLastStep = step === LAST_STEP
const stepOptions = getStep(
{ step, config },
currency,
customInfoRequests,
complianceServices,
emailAuth,
triggers
)
const onContinue = async it => {
const newConfig = R.merge(config, stepOptions.schema.cast(it))
if (isLastStep) {
return save(newConfig)
}
setState({
step: step + 1,
config: newConfig
})
}
const createErrorMessage = (errors, touched, values) => {
const triggerType = values?.triggerType
const containsType = R.contains(triggerType)
const isSuspend = values?.requirement?.requirement === 'suspend'
const isCustom = values?.requirement?.requirement === 'custom'
const hasRequirementError = requirements().hasRequirementError(
errors,
touched,
values
)
const hasCustomRequirementError = requirements().hasCustomRequirementError(
errors,
touched,
values
)
const hasAmountError =
!!errors.threshold &&
!!touched.threshold?.threshold &&
!containsType(['consecutiveDays']) &&
(!values.threshold?.threshold || values.threshold?.threshold < 0)
const hasDaysError =
!!errors.threshold &&
!!touched.threshold?.thresholdDays &&
!containsType(['txAmount']) &&
(!values.threshold?.thresholdDays || values.threshold?.thresholdDays < 0)
if (containsType(['txAmount', 'txVolume', 'txVelocity']) && hasAmountError)
return errors.threshold
if (
containsType(['txVolume', 'txVelocity', 'consecutiveDays']) &&
hasDaysError
)
return errors.threshold
if (
(isSuspend && hasRequirementError) ||
(isCustom && hasCustomRequirementError)
)
return errors.requirement
}
return (
<>
<Modal
title="New compliance trigger"
handleClose={onClose}
width={560}
height={520}
infoPanel={
<InfoPanel
currency={currency}
step={step}
config={config}
liveValues={liveValues}
/>
}
infoPanelHeight={172}
open={true}>
<Stepper className="my-4 mx-0" steps={LAST_STEP} currentStep={step} />
<Formik
validateOnBlur={false}
validateOnChange={true}
enableReinitialize
onSubmit={onContinue}
initialValues={stepOptions.initialValues}
validationSchema={stepOptions.schema}>
{({ errors, touched, values }) => (
<Form className="h-full flex flex-col">
<GetValues setValues={setLiveValues} />
<stepOptions.Component {...stepOptions.props} />
<div className="flex flex-row mt-auto mx-0 mb-6">
{error && <ErrorMessage>Failed to save</ErrorMessage>}
{createErrorMessage(errors, touched, values) && (
<ErrorMessage>
{createErrorMessage(errors, touched, values)}
</ErrorMessage>
)}
<Button className="ml-auto" type="submit">
{isLastStep ? 'Finish' : 'Next'}
</Button>
</div>
</Form>
)}
</Formik>
</Modal>
</>
)
}
export default Wizard

View file

@ -0,0 +1,133 @@
import { useMutation, useQuery, gql } from "@apollo/client";
import * as R from 'ramda'
import React, { memo, useState } from 'react'
import Section from 'src/components/layout/Section'
import { Table as EditableTable } from 'src/components/editableTable'
import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
import {
defaultSchema,
getOverridesSchema,
defaults,
overridesDefaults,
getDefaultSettings,
getOverrides
} from './helper'
const SAVE_CONFIG = gql`
mutation Save($config: JSONObject) {
saveConfig(config: $config)
}
`
const GET_INFO = gql`
query getData {
config
}
`
const GET_CUSTOM_REQUESTS = gql`
query customInfoRequests {
customInfoRequests {
id
customRequest
enabled
}
}
`
const AdvancedTriggersSettings = memo(() => {
const SCREEN_KEY = namespaces.TRIGGERS
const [error, setError] = useState(null)
const [isEditingDefault, setEditingDefault] = useState(false)
const [isEditingOverrides, setEditingOverrides] = useState(false)
const { data, loading: configLoading } = useQuery(GET_INFO)
const { data: customInfoReqData, loading: customInfoLoading } =
useQuery(GET_CUSTOM_REQUESTS)
const customInfoRequests =
R.path(['customInfoRequests'])(customInfoReqData) ?? []
const enabledCustomInfoRequests = R.filter(R.propEq('enabled', true))(
customInfoRequests
)
const loading = configLoading || customInfoLoading
const [saveConfig] = useMutation(SAVE_CONFIG, {
refetchQueries: () => ['getData'],
onError: error => setError(error)
})
const saveDefaults = it => {
const newConfig = toNamespace(SCREEN_KEY)(it.triggersConfig[0])
setError(null)
return saveConfig({
variables: { config: newConfig }
})
}
const saveOverrides = it => {
const config = toNamespace(SCREEN_KEY)(it)
setError(null)
return saveConfig({ variables: { config } })
}
const requirementsData =
data?.config && fromNamespace(SCREEN_KEY)(data?.config)
const requirementsDefaults =
requirementsData && !R.isEmpty(requirementsData)
? requirementsData
: defaults
const requirementsOverrides = requirementsData?.overrides ?? []
const onEditingDefault = (it, editing) => setEditingDefault(editing)
const onEditingOverrides = (it, editing) => setEditingOverrides(editing)
return (
!loading && (
<>
<Section>
<EditableTable
title="Default requirement settings"
error={error?.message}
titleLg
name="triggersConfig"
enableEdit
initialValues={requirementsDefaults}
save={saveDefaults}
validationSchema={defaultSchema}
data={R.of(requirementsDefaults)}
elements={getDefaultSettings()}
setEditing={onEditingDefault}
forceDisable={isEditingOverrides}
/>
</Section>
<Section>
<EditableTable
error={error?.message}
title="Overrides"
titleLg
name="overrides"
enableDelete
enableEdit
enableCreate
initialValues={overridesDefaults}
save={saveOverrides}
validationSchema={getOverridesSchema(
requirementsOverrides,
enabledCustomInfoRequests
)}
data={requirementsOverrides}
elements={getOverrides(enabledCustomInfoRequests)}
setEditing={onEditingOverrides}
forceDisable={isEditingDefault}
/>
</Section>
</>
)
)
})
export default AdvancedTriggersSettings

View file

@ -0,0 +1,175 @@
import * as R from 'ramda'
import Autocomplete from 'src/components/inputs/formik/Autocomplete'
import { getView } from 'src/pages/Triggers/helper'
import * as Yup from 'yup'
const buildAdvancedRequirementOptions = customInfoRequests => {
const base = [
{ display: 'Sanctions', code: 'sanctions' },
{ display: 'ID card image', code: 'idCardPhoto' },
{ display: 'ID data', code: 'idCardData' },
{ display: 'Customer camera', code: 'facephoto' },
{ display: 'US SSN', code: 'usSsn' }
]
const custom = R.map(it => ({
display: it.customRequest.name,
code: it.id
}))(customInfoRequests)
return R.concat(base, custom)
}
const displayRequirement = (code, customInfoRequests) => {
return R.prop(
'display',
R.find(R.propEq('code', code))(
buildAdvancedRequirementOptions(customInfoRequests)
)
)
}
const defaultSchema = Yup.object().shape({
expirationTime: Yup.string().label('Expiration time').required(),
automation: Yup.string()
.label('Automation')
.matches(/(Manual|Automatic)/)
.required()
})
const getOverridesSchema = (values, customInfoRequests) => {
return Yup.object().shape({
id: Yup.string()
.label('Requirement')
.required()
.test({
test() {
const { id, requirement } = this.parent
// If we're editing, filter out the override being edited so that validation schemas don't enter in circular conflicts
const _values = R.filter(it => it.id !== id, values)
if (R.find(R.propEq('requirement', requirement))(_values)) {
return this.createError({
message: `Requirement '${displayRequirement(
requirement,
customInfoRequests
)}' already overridden`
})
}
return true
}
}),
expirationTime: Yup.string().label('Expiration time').required(),
automation: Yup.string()
.label('Automation')
.matches(/(Manual|Automatic)/)
.required()
});
}
const getDefaultSettings = () => {
return [
{
name: 'expirationTime',
header: 'Expiration time',
width: 196,
size: 'sm',
editable: false
},
{
name: 'automation',
header: 'Automation',
width: 196,
size: 'sm',
input: Autocomplete,
inputProps: {
options: [
{ code: 'Automatic', display: 'Automatic' },
{ code: 'Manual', display: 'Manual' }
],
labelProp: 'display',
valueProp: 'code'
}
},
{
name: 'customerAuthentication',
header: 'Customer Auth',
width: 196,
size: 'sm',
input: Autocomplete,
inputProps: {
options: [
{ code: 'SMS', display: 'SMS' },
{ code: 'EMAIL', display: 'EMAIL' }
],
labelProp: 'display',
valueProp: 'code'
}
}
]
}
const getOverrides = customInfoRequests => {
return [
{
name: 'requirement',
header: 'Requirement',
width: 196,
size: 'sm',
view: getView(
buildAdvancedRequirementOptions(customInfoRequests),
'display'
),
input: Autocomplete,
inputProps: {
options: buildAdvancedRequirementOptions(customInfoRequests),
labelProp: 'display',
valueProp: 'code'
}
},
{
name: 'expirationTime',
header: 'Expiration time',
width: 196,
size: 'sm',
editable: false
},
{
name: 'automation',
header: 'Automation',
width: 196,
size: 'sm',
input: Autocomplete,
inputProps: {
options: [
{ code: 'Automatic', display: 'Automatic' },
{ code: 'Manual', display: 'Manual' }
],
labelProp: 'display',
valueProp: 'code'
}
}
]
}
const defaults = [
{
expirationTime: 'Forever',
automation: 'Automatic',
customerAuth: 'SMS'
}
]
const overridesDefaults = {
requirement: '',
expirationTime: 'Forever',
automation: 'Automatic'
}
export {
defaultSchema,
getOverridesSchema,
defaults,
overridesDefaults,
getDefaultSettings,
getOverrides
}

View file

@ -0,0 +1,793 @@
import classnames from 'classnames'
import { Field, useFormikContext } from 'formik'
import * as R from 'ramda'
import React, { memo } from 'react'
import { H4, Label2, Label1, Info1, Info2 } from 'src/components/typography'
import * as Yup from 'yup'
import { NumberInput, RadioGroup, Dropdown } from 'src/components/inputs/formik'
import { transformNumber } from 'src/utils/number'
import { onlyFirstToUpper } from 'src/utils/string'
const triggerType = Yup.string().required()
const threshold = Yup.object().shape({
threshold: Yup.number()
.nullable()
.transform(transformNumber)
.label('Invalid threshold'),
thresholdDays: Yup.number()
.transform(transformNumber)
.nullable()
.label('Invalid threshold days')
})
const requirement = Yup.object().shape({
requirement: Yup.string().required(),
suspensionDays: Yup.number().transform(transformNumber).nullable()
})
const Schema = Yup.object()
.shape({
triggerType,
requirement,
threshold
// direction
})
.test(({ threshold, triggerType }, context) => {
const errorMessages = {
txAmount: threshold => 'Amount must be greater than or equal to 0',
txVolume: threshold => {
const thresholdMessage = 'Volume must be greater than or equal to 0'
const thresholdDaysMessage = 'Days must be greater than 0'
const message = []
if (threshold.threshold < 0) message.push(thresholdMessage)
if (threshold.thresholdDays <= 0) message.push(thresholdDaysMessage)
return message.join(', ')
},
txVelocity: threshold => {
const thresholdMessage = 'Transactions must be greater than 0'
const thresholdDaysMessage = 'Days must be greater than 0'
const message = []
if (threshold.threshold <= 0) message.push(thresholdMessage)
if (threshold.thresholdDays <= 0) message.push(thresholdDaysMessage)
return message.join(', ')
},
consecutiveDays: threshold => 'Days must be greater than 0'
}
const thresholdValidator = {
txAmount: threshold => threshold.threshold >= 0,
txVolume: threshold =>
threshold.threshold >= 0 && threshold.thresholdDays > 0,
txVelocity: threshold =>
threshold.threshold > 0 && threshold.thresholdDays > 0,
consecutiveDays: threshold => threshold.thresholdDays > 0
}
if (triggerType && thresholdValidator[triggerType](threshold)) return
return context.createError({
path: 'threshold',
message: errorMessages[triggerType](threshold)
})
})
.test(({ requirement }, context) => {
const requirementValidator = requirement =>
requirement.requirement === 'suspend'
? requirement.suspensionDays > 0
: true
if (requirement && requirementValidator(requirement)) return
return context.createError({
path: 'requirement',
message: 'Suspension days must be greater than 0'
})
})
// TYPE
const typeSchema = Yup.object()
.shape({
triggerType: Yup.string('The trigger type must be a string').required(
'The trigger type is required'
),
threshold: Yup.object({
threshold: Yup.number().transform(transformNumber).nullable(),
thresholdDays: Yup.number().transform(transformNumber).nullable()
})
})
.test(({ threshold, triggerType }, context) => {
const errorMessages = {
txAmount: threshold => 'Amount must be greater than or equal to 0',
txVolume: threshold => {
const thresholdMessage = 'Volume must be greater than or equal to 0'
const thresholdDaysMessage = 'Days must be greater than 0'
const message = []
if (!threshold.threshold || threshold.threshold < 0)
message.push(thresholdMessage)
if (!threshold.thresholdDays || threshold.thresholdDays <= 0)
message.push(thresholdDaysMessage)
return message.join(', ')
},
txVelocity: threshold => {
const thresholdMessage = 'Transactions must be greater than 0'
const thresholdDaysMessage = 'Days must be greater than 0'
const message = []
if (!threshold.threshold || threshold.threshold <= 0)
message.push(thresholdMessage)
if (!threshold.thresholdDays || threshold.thresholdDays <= 0)
message.push(thresholdDaysMessage)
return message.join(', ')
},
consecutiveDays: threshold => 'Days must be greater than 0'
}
const thresholdValidator = {
txAmount: threshold => threshold.threshold >= 0,
txVolume: threshold =>
threshold.threshold >= 0 && threshold.thresholdDays > 0,
txVelocity: threshold =>
threshold.threshold > 0 && threshold.thresholdDays > 0,
consecutiveDays: threshold => threshold.thresholdDays > 0
}
if (!triggerType) return
if (triggerType && thresholdValidator[triggerType](threshold)) return
return context.createError({
path: 'threshold',
message: errorMessages[triggerType](threshold)
})
})
const typeOptions = [
{ display: 'Transaction amount', code: 'txAmount' },
{
display: 'Transaction volume',
code: 'txVolume'
},
{ display: 'Transaction velocity', code: 'txVelocity' },
{ display: 'Consecutive days', code: 'consecutiveDays' }
]
const Type = ({ ...props }) => {
const { errors, touched, values, setTouched, handleChange } =
useFormikContext()
const typeClass = {
'text-tomato': errors.triggerType && touched.triggerType
}
const containsType = R.contains(values?.triggerType)
const isThresholdCurrencyEnabled = containsType(['txAmount', 'txVolume'])
const isTransactionAmountEnabled = containsType(['txVelocity'])
const isThresholdDaysEnabled = containsType(['txVolume', 'txVelocity'])
const isConsecutiveDaysEnabled = containsType(['consecutiveDays'])
const hasAmountError =
!!errors.threshold &&
!!touched.threshold?.threshold &&
!isConsecutiveDaysEnabled &&
(!values.threshold?.threshold || values.threshold?.threshold < 0)
const hasDaysError =
!!errors.threshold &&
!!touched.threshold?.thresholdDays &&
!containsType(['txAmount']) &&
(!values.threshold?.thresholdDays || values.threshold?.thresholdDays < 0)
const triggerTypeError = !!(hasDaysError || hasAmountError)
const thresholdClass = {
'text-tomato': triggerTypeError
}
const isRadioGroupActive = () => {
return (
isThresholdCurrencyEnabled ||
isTransactionAmountEnabled ||
isThresholdDaysEnabled ||
isConsecutiveDaysEnabled
)
}
return (
<>
<div className="flex items-center">
<H4 className={classnames(typeClass)}>Choose trigger type</H4>
</div>
<Field
component={RadioGroup}
name="triggerType"
options={typeOptions}
labelClassName="h-10 py-0 px-3"
radioClassName="p-1 m-1"
className="flex-row"
onChange={e => {
handleChange(e)
setTouched({
threshold: false,
thresholdDays: false
})
}}
/>
<div className="flex flex-col">
{isRadioGroupActive() && (
<H4 className={classnames(thresholdClass, 'mt-12')}>Threshold</H4>
)}
<div className="flex flex-row">
{isThresholdCurrencyEnabled && (
<>
<Field
className="mr-2 w-19"
component={NumberInput}
size="lg"
name="threshold.threshold"
error={hasAmountError}
/>
<Info1 className="mt-2">{props.currency}</Info1>
</>
)}
{isTransactionAmountEnabled && (
<>
<Field
className="mr-2 w-19"
component={NumberInput}
size="lg"
name="threshold.threshold"
error={hasAmountError}
/>
<Info1 className="mt-2">transactions</Info1>
</>
)}
{isThresholdDaysEnabled && (
<>
<Info1 className={classnames(typeClass, 'mx-2 mt-2')}>in</Info1>
<Field
className="mr-2 w-19"
component={NumberInput}
size="lg"
name="threshold.thresholdDays"
error={hasDaysError}
/>
<Info1 className="mt-2">days</Info1>
</>
)}
{isConsecutiveDaysEnabled && (
<>
<Field
className="mr-2 w-19"
component={NumberInput}
size="lg"
name="threshold.thresholdDays"
error={hasDaysError}
/>
<Info1 className="mt-2">consecutive days</Info1>
</>
)}
</div>
</div>
</>
)
}
const type = currency => ({
schema: typeSchema,
options: typeOptions,
Component: Type,
props: { currency },
initialValues: {
triggerType: '',
threshold: { threshold: '', thresholdDays: '' }
}
})
const requirementSchema = Yup.object()
.shape({
requirement: Yup.object({
requirement: Yup.string().required(),
suspensionDays: Yup.number().when('requirement', {
is: value => value === 'suspend',
then: schema => schema.nullable().transform(transformNumber),
otherwise: schema => schema.nullable().transform(() => null)
}),
customInfoRequestId: Yup.string().when('requirement', {
is: value => value !== 'custom',
then: schema => schema.nullable().transform(() => '')
}),
externalService: Yup.string().when('requirement', {
is: value => value !== 'external',
then: schema => schema.nullable().transform(() => '')
})
}).required()
})
.test(({ requirement }, context) => {
const requirementValidator = (requirement, type) => {
switch (type) {
case 'suspend':
return requirement.requirement === type
? requirement.suspensionDays > 0
: true
case 'custom':
return requirement.requirement === type
? !R.isNil(requirement.customInfoRequestId)
: true
case 'external':
return requirement.requirement === type
? !R.isNil(requirement.externalService)
: true
default:
return true
}
}
if (requirement && !requirementValidator(requirement, 'suspend'))
return context.createError({
path: 'requirement',
message: 'Suspension days must be greater than 0'
})
if (requirement && !requirementValidator(requirement, 'custom'))
return context.createError({
path: 'requirement',
message: 'You must select an item'
})
if (requirement && !requirementValidator(requirement, 'external'))
return context.createError({
path: 'requirement',
message: 'You must select an item'
})
})
const requirementOptions = [
{ display: 'SMS verification', code: 'sms' },
{
display: 'Email verification',
code: 'email'
},
{ display: 'ID card image', code: 'idCardPhoto' },
{
display: 'ID data',
code: 'idCardData'
},
{ display: 'Customer camera', code: 'facephoto' },
{ display: 'Sanctions', code: 'sanctions' },
{
display: 'US SSN',
code: 'usSsn'
}, // { display: 'Super user', code: 'superuser' },
{ display: 'Suspend', code: 'suspend' },
{ display: 'Block', code: 'block' },
{
display: 'External verification',
code: 'external'
}
]
const hasRequirementError = (errors, touched, values) =>
!!errors.requirement &&
!!touched.requirement?.suspensionDays &&
(!values.requirement?.suspensionDays ||
values.requirement?.suspensionDays < 0)
const hasCustomRequirementError = (errors, touched, values) =>
!!errors.requirement &&
!!touched.requirement?.customInfoRequestId &&
(!values.requirement?.customInfoRequestId ||
!R.isNil(values.requirement?.customInfoRequestId))
const hasExternalRequirementError = (errors, touched, values) =>
!!errors.requirement &&
!!touched.requirement?.externalService &&
!values.requirement?.externalService
const Requirement = ({
config = {},
triggers,
emailAuth,
complianceServices,
customInfoRequests = []
}) => {
const { touched, errors, values, handleChange, setTouched } =
useFormikContext()
const isSuspend = values?.requirement?.requirement === 'suspend'
const isCustom = values?.requirement?.requirement === 'custom'
const isExternal = values?.requirement?.requirement === 'external'
const customRequirementsInUse = R.reduce(
(acc, value) => {
if (value.requirement.requirement === 'custom')
acc.push({
triggerType: value.triggerType,
id: value.requirement.customInfoRequestId
})
return acc
},
[],
triggers
)
const availableCustomRequirements = R.filter(
it =>
!R.includes(
{
triggerType: config.triggerType,
id: it.id
},
customRequirementsInUse
),
customInfoRequests
)
const makeCustomReqOptions = () =>
availableCustomRequirements.map(it => ({
value: it.id,
display: it.customRequest.name
}))
const enableCustomRequirement = !R.isEmpty(availableCustomRequirements)
const customInfoOption = {
display: 'Custom information requirement',
code: 'custom'
}
const itemToRemove = emailAuth ? 'sms' : 'email'
const reqOptions = requirementOptions.filter(it => it.code !== itemToRemove)
const options = R.clone(reqOptions)
enableCustomRequirement && options.push(customInfoOption)
const titleClass = {
'text-tomato':
(!!errors.requirement && !isSuspend && !isCustom && !isExternal) ||
(isSuspend && hasRequirementError(errors, touched, values)) ||
(isCustom && hasCustomRequirementError(errors, touched, values)) ||
(isExternal && hasExternalRequirementError(errors, touched, values))
}
return (
<>
<div className="flex items-center">
<H4 className={classnames(titleClass)}>Choose a requirement</H4>
</div>
<Field
component={RadioGroup}
name="requirement.requirement"
options={options}
labelClassName="h-10 p-0"
radioClassName="p-1 m-1"
className="flex-row grid grid-cols-[182px_162px_181px]"
onChange={e => {
handleChange(e)
setTouched({
suspensionDays: false
})
}}
/>
{isSuspend && (
<Field
className="mr-2 w-19"
component={NumberInput}
label="Days"
size="lg"
name="requirement.suspensionDays"
error={hasRequirementError(errors, touched, values)}
/>
)}
{isCustom && (
<div>
<Field
className="mt-4 min-w-[155px]"
component={Dropdown}
label="Available requests"
name="requirement.customInfoRequestId"
options={makeCustomReqOptions()}
/>
</div>
)}
{isExternal && (
<div className="flex flex-col gap-4">
<Field
className="mt-4 w-[155px]"
component={Dropdown}
label="Service"
name="requirement.externalService"
options={complianceServices.map(it => ({
value: it.code,
display: it.display
}))}
/>
</div>
)}
</>
)
}
const requirements = (
config,
triggers,
customInfoRequests,
complianceServices,
emailAuth
) => ({
schema: requirementSchema,
options: requirementOptions,
Component: Requirement,
props: {
config,
triggers,
customInfoRequests,
emailAuth,
complianceServices
},
hasRequirementError: hasRequirementError,
hasCustomRequirementError: hasCustomRequirementError,
hasExternalRequirementError: hasExternalRequirementError,
initialValues: {
requirement: {
requirement: '',
suspensionDays: '',
customInfoRequestId: '',
externalService: ''
}
}
})
const getView = (data, code, compare) => it => {
if (!data) return ''
return R.compose(R.prop(code), R.find(R.propEq(compare ?? 'code', it)))(data)
}
const customReqIdMatches = customReqId => it => {
return it.id === customReqId
}
const RequirementInput = ({ customInfoRequests = [] }) => {
const { values } = useFormikContext()
const requirement = values?.requirement?.requirement
const customRequestId =
R.path(['requirement', 'customInfoRequestId'])(values) ?? ''
const isSuspend = requirement === 'suspend'
const display = customRequestId
? (R.path(['customRequest', 'name'])(
R.find(customReqIdMatches(customRequestId))(customInfoRequests)
) ?? '')
: getView(requirementOptions, 'display')(requirement)
return (
<div className="flex items-baseline">
{`${display} ${isSuspend ? 'for' : ''}`}
{isSuspend && (
<Field
bold
className="w-8"
name="requirement.suspensionDays"
component={NumberInput}
textAlign="center"
/>
)}
{isSuspend && 'days'}
</div>
)
}
const RequirementView = ({
requirement,
suspensionDays,
customInfoRequestId,
externalService,
customInfoRequests = []
}) => {
const display =
requirement === 'custom'
? (R.path(['customRequest', 'name'])(
R.find(customReqIdMatches(customInfoRequestId))(customInfoRequests)
) ?? '')
: requirement === 'external'
? `External verification (${onlyFirstToUpper(externalService)})`
: getView(requirementOptions, 'display')(requirement)
const isSuspend = requirement === 'suspend'
return (
<div className="flex items-baseline">
{`${display} ${isSuspend ? 'for' : ''}`}
{isSuspend && (
<Info2 className="mx-2" noMargin>
{suspensionDays}
</Info2>
)}
{isSuspend && 'days'}
</div>
)
}
const DisplayThreshold = ({ config, currency, isEdit }) => {
const inputClasses = {
'-mt-1': true,
'w-13': config?.triggerType === 'txVelocity',
'w-15': config?.triggerType === 'consecutiveDays'
}
const threshold = config?.threshold?.threshold
const thresholdDays = config?.threshold?.thresholdDays
const Threshold = isEdit ? (
<Field
bold
className={classnames(inputClasses)}
name="threshold.threshold"
component={NumberInput}
textAlign="right"
/>
) : (
<Info2 noMargin>{threshold}</Info2>
)
const ThresholdDays = isEdit ? (
<Field
bold
className={classnames(inputClasses)}
name="threshold.thresholdDays"
component={NumberInput}
textAlign="right"
/>
) : (
<Info2 noMargin>{thresholdDays}</Info2>
)
switch (config?.triggerType) {
case 'txAmount':
return (
<div className="flex items-baseline justify-end">
{Threshold}
<Label2 noMargin className="ml-2">
{currency}
</Label2>
</div>
)
case 'txVolume':
return (
<div className="flex items-baseline justify-end">
{Threshold}
<Label2 noMargin className="ml-2">
{currency}
</Label2>
<Label1 noMargin className="mx-2">
in
</Label1>
{ThresholdDays}
<Label1 noMargin className="ml-2">
days
</Label1>
</div>
)
case 'txVelocity':
return (
<div className="flex items-baseline justify-end">
{Threshold}
<Label1 className="mx-2" noMargin>
transactions in
</Label1>
{ThresholdDays}
<Label1 className="ml-2" noMargin>
days
</Label1>
</div>
)
case 'consecutiveDays':
return (
<div className="flex items-baseline justify-end">
{ThresholdDays}
<Label1 className="mx-2" noMargin>
days
</Label1>
</div>
)
default:
return ''
}
}
const ThresholdInput = memo(({ currency }) => {
const { values } = useFormikContext()
return <DisplayThreshold isEdit={true} config={values} currency={currency} />
})
const ThresholdView = ({ config, currency }) => {
return <DisplayThreshold config={config} currency={currency} />
}
const getElements = (currency, customInfoRequests) => [
{
name: 'triggerType',
size: 'sm',
width: 230,
input: ({ field: { value: name } }) => (
<>{getView(typeOptions, 'display')(name)}</>
),
view: getView(typeOptions, 'display'),
inputProps: {
options: typeOptions,
valueProp: 'code',
labelProp: 'display',
optionsLimit: null
}
},
{
name: 'requirement',
size: 'sm',
width: 260,
bypassField: true,
input: () => <RequirementInput customInfoRequests={customInfoRequests} />,
view: it => (
<RequirementView {...it} customInfoRequests={customInfoRequests} />
)
},
{
name: 'threshold',
size: 'sm',
width: 254,
textAlign: 'right',
input: () => <ThresholdInput currency={currency} />,
view: (it, config) => <ThresholdView config={config} currency={currency} />
}
]
const triggerOrder = R.map(R.prop('code'))(typeOptions)
const sortBy = [
R.comparator(
(a, b) =>
triggerOrder.indexOf(a.triggerType) < triggerOrder.indexOf(b.triggerType)
)
]
const fromServer = triggers => {
return R.map(
({
requirement,
suspensionDays,
threshold,
thresholdDays,
customInfoRequestId,
externalService,
...rest
}) => ({
requirement: {
requirement,
suspensionDays,
customInfoRequestId,
externalService
},
threshold: {
threshold,
thresholdDays
},
...rest
})
)(triggers)
}
const toServer = triggers =>
R.map(({ requirement, threshold, ...rest }) => ({
requirement: requirement.requirement,
suspensionDays: requirement.suspensionDays,
threshold: threshold.threshold,
thresholdDays: threshold.thresholdDays,
customInfoRequestId: requirement.customInfoRequestId,
externalService: requirement.externalService,
...rest
}))(triggers)
export {
Schema,
getElements, // txDirection,
type,
requirements,
sortBy,
fromServer,
toServer,
getView,
requirementOptions
}

View file

@ -0,0 +1,3 @@
import Triggers from './Triggers'
export default Triggers