lamassu-server/new-lamassu-admin/src/pages/Triggers/helper.js
2022-10-11 23:46:33 +01:00

922 lines
24 KiB
JavaScript

import { makeStyles, Box } from '@material-ui/core'
import classnames from 'classnames'
import { Field, useFormikContext } from 'formik'
import * as R from 'ramda'
import React, { memo } from 'react'
import * as Yup from 'yup'
import { NumberInput, RadioGroup, Dropdown } from 'src/components/inputs/formik'
import { H4, Label2, Label1, Info1, Info2 } from 'src/components/typography'
import { errorColor } from 'src/styling/variables'
import { transformNumber } from 'src/utils/number'
// import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
// import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
const useStyles = makeStyles({
radioLabel: {
height: 40,
padding: [[0, 10]]
},
radio: {
padding: 4,
margin: 4
},
radioGroup: {
flexDirection: 'row'
},
error: {
color: errorColor
},
specialLabel: {
height: 40,
padding: 0
},
specialGrid: {
display: 'grid',
gridTemplateColumns: [[182, 162, 181]]
},
directionIcon: {
marginRight: 2
},
directionName: {
marginLeft: 6
},
thresholdWrapper: {
display: 'flex',
flexDirection: 'column'
},
thresholdTitle: {
marginTop: 50
},
thresholdContentWrapper: {
display: 'flex',
flexDirection: 'row'
},
thresholdField: {
marginRight: 6,
width: 75
},
description: {
marginTop: 7
},
space: {
marginLeft: 6,
marginRight: 6
},
lastSpace: {
marginLeft: 6
},
suspensionDays: {
width: 34
},
input: {
marginTop: -2
},
limitedInput: {
width: 50
},
daysInput: {
width: 60
},
dropdownField: {
marginTop: 16,
minWidth: 155
}
})
// const direction = Yup.string().required()
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'
})
})
// Direction V2 only
// const directionSchema = Yup.object().shape({ direction })
// const directionOptions = [
// {
// display: 'Both',
// code: 'both'
// },
// {
// display: 'Only cash-in',
// code: 'cashIn'
// },
// {
// display: 'Only cash-out',
// code: 'cashOut'
// }
// ]
// const directionOptions2 = [
// {
// display: (
// <>
// <TxInIcon /> in
// </>
// ),
// code: 'cashIn'
// },
// {
// display: (
// <>
// <TxOutIcon /> out
// </>
// ),
// code: 'cashOut'
// },
// {
// display: (
// <>
// <Box display="flex">
// <Box mr={0.25}>
// <TxOutIcon />
// </Box>
// <Box>
// <TxInIcon />
// </Box>
// </Box>
// </>
// ),
// code: 'both'
// }
// ]
// const Direction = () => {
// const classes = useStyles()
// const { errors } = useFormikContext()
// const titleClass = {
// [classes.error]: errors.direction
// }
// return (
// <>
// <Box display="flex" alignItems="center">
// <H4 className={classnames(titleClass)}>
// In which type of transactions will it trigger?
// </H4>
// </Box>
// <Field
// component={RadioGroup}
// name="direction"
// options={directionOptions}
// labelClassName={classes.radioLabel}
// radioClassName={classes.radio}
// className={classes.radioGroup}
// />
// </>
// )
// }
// const txDirection = {
// schema: directionSchema,
// options: directionOptions,
// Component: Direction,
// initialValues: { direction: '' }
// }
// 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 classes = useStyles()
const {
errors,
touched,
values,
setTouched,
handleChange
} = useFormikContext()
const typeClass = {
[classes.error]: 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 = {
[classes.error]: triggerTypeError
}
const isRadioGroupActive = () => {
return (
isThresholdCurrencyEnabled ||
isTransactionAmountEnabled ||
isThresholdDaysEnabled ||
isConsecutiveDaysEnabled
)
}
return (
<>
<Box display="flex" alignItems="center">
<H4 className={classnames(typeClass)}>Choose trigger type</H4>
</Box>
<Field
component={RadioGroup}
name="triggerType"
options={typeOptions}
labelClassName={classes.radioLabel}
radioClassName={classes.radio}
className={classes.radioGroup}
onChange={e => {
handleChange(e)
setTouched({
threshold: false,
thresholdDays: false
})
}}
/>
<div className={classes.thresholdWrapper}>
{isRadioGroupActive() && (
<H4 className={classnames(thresholdClass, classes.thresholdTitle)}>
Threshold
</H4>
)}
<div className={classes.thresholdContentWrapper}>
{isThresholdCurrencyEnabled && (
<>
<Field
className={classes.thresholdField}
component={NumberInput}
size="lg"
name="threshold.threshold"
error={hasAmountError}
/>
<Info1 className={classnames(classes.description)}>
{props.currency}
</Info1>
</>
)}
{isTransactionAmountEnabled && (
<>
<Field
className={classes.thresholdField}
component={NumberInput}
size="lg"
name="threshold.threshold"
error={hasAmountError}
/>
<Info1 className={classnames(classes.description)}>
transactions
</Info1>
</>
)}
{isThresholdDaysEnabled && (
<>
<Info1
className={classnames(
typeClass,
classes.space,
classes.description
)}>
in
</Info1>
<Field
className={classes.thresholdField}
component={NumberInput}
size="lg"
name="threshold.thresholdDays"
error={hasDaysError}
/>
<Info1 className={classnames(classes.description)}>days</Info1>
</>
)}
{isConsecutiveDaysEnabled && (
<>
<Field
className={classes.thresholdField}
component={NumberInput}
size="lg"
name="threshold.thresholdDays"
error={hasDaysError}
/>
<Info1 className={classnames(classes.description)}>
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: Yup.number()
.nullable()
.transform(transformNumber),
otherwise: Yup.number()
.nullable()
.transform(() => null)
}),
customInfoRequestId: Yup.string().when('requirement', {
is: value => value === 'custom',
then: Yup.string(),
otherwise: Yup.string()
.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
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'
})
})
const requirementOptions = [
{ display: 'SMS verification', code: 'sms' },
{ 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' }
]
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 Requirement = ({ customInfoRequests }) => {
const classes = useStyles()
const {
touched,
errors,
values,
handleChange,
setTouched
} = useFormikContext()
const isSuspend = values?.requirement?.requirement === 'suspend'
const isCustom = values?.requirement?.requirement === 'custom'
const makeCustomReqOptions = () =>
customInfoRequests.map(it => ({
value: it.id,
display: it.customRequest.name
}))
const enableCustomRequirement = customInfoRequests?.length > 0
const customInfoOption = {
display: 'Custom information requirement',
code: 'custom'
}
const options = enableCustomRequirement
? [...requirementOptions, customInfoOption]
: [...requirementOptions]
const titleClass = {
[classes.error]:
(!!errors.requirement && !isSuspend && !isCustom) ||
(isSuspend && hasRequirementError(errors, touched, values)) ||
(isCustom && hasCustomRequirementError(errors, touched, values))
}
return (
<>
<Box display="flex" alignItems="center">
<H4 className={classnames(titleClass)}>Choose a requirement</H4>
</Box>
<Field
component={RadioGroup}
name="requirement.requirement"
options={options}
labelClassName={classes.specialLabel}
radioClassName={classes.radio}
className={classnames(classes.radioGroup, classes.specialGrid)}
onChange={e => {
handleChange(e)
setTouched({
suspensionDays: false
})
}}
/>
{isSuspend && (
<Field
className={classes.thresholdField}
component={NumberInput}
label="Days"
size="lg"
name="requirement.suspensionDays"
error={hasRequirementError(errors, touched, values)}
/>
)}
{isCustom && (
<div>
<Field
className={classes.dropdownField}
component={Dropdown}
label="Available requests"
name="requirement.customInfoRequestId"
options={makeCustomReqOptions()}
/>
</div>
)}
</>
)
}
const requirements = customInfoRequests => ({
schema: requirementSchema,
options: requirementOptions,
Component: Requirement,
props: { customInfoRequests },
hasRequirementError: hasRequirementError,
hasCustomRequirementError: hasCustomRequirementError,
initialValues: {
requirement: {
requirement: '',
suspensionDays: '',
customInfoRequestId: ''
}
}
})
const getView = (data, code, compare) => it => {
if (!data) return ''
return R.compose(R.prop(code), R.find(R.propEq(compare ?? 'code', it)))(data)
}
// const DirectionDisplay = ({ code }) => {
// const classes = useStyles()
// const displayName = getView(directionOptions, 'display')(code)
// const showCashIn = code === 'cashIn' || code === 'both'
// const showCashOut = code === 'cashOut' || code === 'both'
// return (
// <div>
// {showCashOut && <TxOutIcon className={classes.directionIcon} />}
// {showCashIn && <TxInIcon className={classes.directionIcon} />}
// <span className={classes.directionName}>{displayName}</span>
// </div>
// )
// }
const customReqIdMatches = customReqId => it => {
return it.id === customReqId
}
const RequirementInput = ({ customInfoRequests }) => {
const { values } = useFormikContext()
const classes = useStyles()
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 (
<Box display="flex" alignItems="baseline">
{`${display} ${isSuspend ? 'for' : ''}`}
{isSuspend && (
<Field
bold
className={classes.suspensionDays}
name="requirement.suspensionDays"
component={NumberInput}
textAlign="center"
/>
)}
{isSuspend && 'days'}
</Box>
)
}
const RequirementView = ({
requirement,
suspensionDays,
customInfoRequestId,
customInfoRequests
}) => {
const classes = useStyles()
const display =
requirement === 'custom'
? R.path(['customRequest', 'name'])(
R.find(customReqIdMatches(customInfoRequestId))(customInfoRequests)
) ?? ''
: getView(requirementOptions, 'display')(requirement)
const isSuspend = requirement === 'suspend'
return (
<Box display="flex" alignItems="baseline">
{`${display} ${isSuspend ? 'for' : ''}`}
{isSuspend && (
<Info2 className={classes.space} noMargin>
{suspensionDays}
</Info2>
)}
{isSuspend && 'days'}
</Box>
)
}
const DisplayThreshold = ({ config, currency, isEdit }) => {
const classes = useStyles()
const inputClasses = {
[classes.input]: true,
[classes.limitedInput]: config?.triggerType === 'txVelocity',
[classes.daysInput]: 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 (
<Box display="flex" alignItems="baseline" justifyContent="right">
{Threshold}
<Label2 noMargin className={classes.lastSpace}>
{currency}
</Label2>
</Box>
)
case 'txVolume':
return (
<Box display="flex" alignItems="baseline" justifyContent="right">
{Threshold}
<Label2 noMargin className={classes.lastSpace}>
{currency}
</Label2>
<Label1 noMargin className={classes.space}>
in
</Label1>
{ThresholdDays}
<Label1 noMargin className={classes.lastSpace}>
days
</Label1>
</Box>
)
case 'txVelocity':
return (
<Box display="flex" alignItems="baseline" justifyContent="right">
{Threshold}
<Label1 className={classes.space} noMargin>
transactions in
</Label1>
{ThresholdDays}
<Label1 className={classes.lastSpace} noMargin>
days
</Label1>
</Box>
)
case 'consecutiveDays':
return (
<Box display="flex" alignItems="baseline" justifyContent="right">
{ThresholdDays}
<Label1 className={classes.lastSpace} noMargin>
days
</Label1>
</Box>
)
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, classes, 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: 230,
bypassField: true,
input: () => <RequirementInput customInfoRequests={customInfoRequests} />,
view: it => (
<RequirementView {...it} customInfoRequests={customInfoRequests} />
)
},
{
name: 'threshold',
size: 'sm',
width: 284,
textAlign: 'right',
input: () => <ThresholdInput currency={currency} />,
view: (it, config) => <ThresholdView config={config} currency={currency} />
}
// {
// name: 'direction',
// size: 'sm',
// width: 282,
// view: it => <DirectionDisplay code={it} />,
// input: RadioGroup,
// inputProps: {
// labelClassName: classes.tableRadioLabel,
// className: classes.tableRadioGroup,
// options: directionOptions2
// }
// }
]
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, customInfoRequests) => {
return R.map(
({
requirement,
suspensionDays,
threshold,
thresholdDays,
customInfoRequestId,
...rest
}) => ({
requirement: {
requirement,
suspensionDays,
customInfoRequestId
},
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,
...rest
}))(triggers)
export {
Schema,
getElements,
// txDirection,
type,
requirements,
sortBy,
fromServer,
toServer,
getView,
requirementOptions
}