diff --git a/centrifuge-app/src/components/InvestRedeem/InvestRedeem.tsx b/centrifuge-app/src/components/InvestRedeem/InvestRedeem.tsx index e8c7d97059..c44be5b4d0 100644 --- a/centrifuge-app/src/components/InvestRedeem/InvestRedeem.tsx +++ b/centrifuge-app/src/components/InvestRedeem/InvestRedeem.tsx @@ -335,6 +335,8 @@ const OnboardingButton = ({ networks }: { networks: Network[] | undefined }) => showWallets(networks?.length === 1 ? networks[0] : undefined) } else if (investStatus === 'request') { window.open(`mailto:${metadata?.pool?.issuer.email}?subject=New%20Investment%20Inquiry`) + } else if (metadata?.onboarding?.externalOnboardingUrl) { + window.open(metadata.onboarding.externalOnboardingUrl) } else { history.push(`/onboarding?poolId=${state.poolId}&trancheId=${state.trancheId}`) } diff --git a/centrifuge-app/src/pages/IssuerPool/Configuration/OnboardingConfig.tsx b/centrifuge-app/src/pages/IssuerPool/Configuration/OnboardingConfig.tsx deleted file mode 100644 index 2f145539aa..0000000000 --- a/centrifuge-app/src/pages/IssuerPool/Configuration/OnboardingConfig.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { PoolMetadata } from '@centrifuge/centrifuge-js' -import { useCentrifuge, useCentrifugeTransaction } from '@centrifuge/centrifuge-react' -import { Button, FileUpload, Stack } from '@centrifuge/fabric' -import { Form, FormikProvider, useFormik } from 'formik' -import * as React from 'react' -import { useParams } from 'react-router' -import { lastValueFrom } from 'rxjs' -import { ButtonGroup } from '../../../components/ButtonGroup' -import { PageSection } from '../../../components/PageSection' -import { getFileDataURI } from '../../../utils/getFileDataURI' -import { usePool, usePoolMetadata } from '../../../utils/usePools' - -type AgreementsUpload = { - agreements: { trancheId: string; file: File }[] -} - -const initialValues: AgreementsUpload = { - agreements: [], -} - -export const OnboardingConfig: React.FC = () => { - const { pid: poolId } = useParams<{ pid: string }>() - const pool = usePool(poolId) - const { data: poolMetadata } = usePoolMetadata(pool) as { data: PoolMetadata } - const [isEditing, setIsEditing] = React.useState(false) - const centrifuge = useCentrifuge() - - const { execute: updateConfigTx } = useCentrifugeTransaction('Update pool config', (cent) => cent.pools.setMetadata, { - onSuccess: () => { - setIsEditing(false) - }, - }) - - const form = useFormik({ - initialValues, - onSubmit: async (values, actions) => { - if (!values.agreements || !poolMetadata) { - return - } - let onboardingAgreements: PoolMetadata['onboarding'] = { - agreements: {}, - } - for (const i of values.agreements) { - const uri = await getFileDataURI(i.file) - const pinnedAgreement = await lastValueFrom(centrifuge.metadata.pinFile(uri)) - onboardingAgreements = { - agreements: { - ...onboardingAgreements.agreements, - [i.trancheId]: { ipfsHash: pinnedAgreement.ipfsHash }, - }, - } - } - - const amendedMetadata: PoolMetadata = { - ...poolMetadata, - onboarding: onboardingAgreements, - } - updateConfigTx([poolId, amendedMetadata]) - actions.setSubmitting(false) - }, - }) - - return ( - -
- - - - - ) : ( - - ) - } - > - - {pool.tranches.map((tranche) => { - return ( - a?.trancheId === tranche.id)?.file ?? - null - } - onFileChange={(file) => { - if (file && !form.values.agreements.find((a) => a?.trancheId === tranche.id)) { - form.setFieldValue('agreements', [ - ...form.values.agreements, - { trancheId: tranche.id, file: file }, - ]) - } - }} - accept="application/pdf" - label={`Upload a pdf subscription agreement for ${tranche.currency.symbol}`} - placeholder="Choose a file..." - /> - ) - })} - - -
-
- ) -} diff --git a/centrifuge-app/src/pages/IssuerPool/Configuration/index.tsx b/centrifuge-app/src/pages/IssuerPool/Configuration/index.tsx index 6737f8f272..0920b59f0f 100644 --- a/centrifuge-app/src/pages/IssuerPool/Configuration/index.tsx +++ b/centrifuge-app/src/pages/IssuerPool/Configuration/index.tsx @@ -11,7 +11,6 @@ import { Details } from './Details' import { EpochAndTranches } from './EpochAndTranches' import { Issuer } from './Issuer' import { LoanTemplates } from './LoanTemplates' -import { OnboardingConfig } from './OnboardingConfig' import { PoolConfig } from './PoolConfig' import { WriteOffGroups } from './WriteOffGroups' @@ -41,7 +40,6 @@ const IssuerPoolConfiguration: React.FC = () => { - {editPoolConfig && } )} diff --git a/centrifuge-app/src/pages/IssuerPool/Investors/InvestorStatus.tsx b/centrifuge-app/src/pages/IssuerPool/Investors/InvestorStatus.tsx new file mode 100644 index 0000000000..44d21b107c --- /dev/null +++ b/centrifuge-app/src/pages/IssuerPool/Investors/InvestorStatus.tsx @@ -0,0 +1,159 @@ +import { findBalance, Pool, Token } from '@centrifuge/centrifuge-js' +import { useBalances, useCentrifugeTransaction } from '@centrifuge/centrifuge-react' +import { + Button, + Grid, + IconAlertCircle, + IconCheckCircle, + IconInfoFailed, + IconMinus, + IconPlus, + SearchInput, + Shelf, + Stack, + Text, + TextWithPlaceholder, +} from '@centrifuge/fabric' +import { isAddress } from '@polkadot/util-crypto' +import React from 'react' +import { useParams } from 'react-router' +import { DataTable } from '../../../components/DataTable' +import { PageSection } from '../../../components/PageSection' +import { usePermissions } from '../../../utils/usePermissions' +import { useOrder, usePool } from '../../../utils/usePools' + +const SevenDaysMs = (7 * 24 + 1) * 60 * 60 * 1000 // 1 hour margin + +export const InvestorStatus: React.FC = () => { + const { pid: poolId } = useParams<{ pid: string }>() + const [address, setAddress] = React.useState('') + const validAddress = isAddress(address) ? address : undefined + const permissions = usePermissions(validAddress) + const [pendingTrancheId, setPendingTrancheId] = React.useState('') + + const { execute, isLoading: isTransactionPending } = useCentrifugeTransaction( + 'Update investor', + (cent) => cent.pools.updatePoolRoles, + {} + ) + + const allowedTranches = Object.entries(permissions?.pools[poolId]?.tranches ?? {}) + .filter(([, till]) => new Date(till).getTime() - Date.now() > SevenDaysMs) + .map(([tid]) => tid) + + const pool = usePool(poolId) as Pool + + function toggleAllowed(trancheId: string) { + if (!validAddress) return + const isAllowed = allowedTranches.includes(trancheId) + const OneHundredYearsFromNow = Math.floor(Date.now() / 1000 + 10 * 365 * 24 * 60 * 60) + const SevenDaysFromNow = Math.floor((Date.now() + SevenDaysMs) / 1000) + + if (isAllowed) { + execute([poolId, [], [[validAddress, { TrancheInvestor: [trancheId, OneHundredYearsFromNow] }]]]) + } else { + execute([poolId, [[validAddress, { TrancheInvestor: [trancheId, SevenDaysFromNow] }]], []]) + } + setPendingTrancheId(trancheId) + } + + return ( + + + + setAddress(e.target.value)} + placeholder="Enter address..." + clear={() => setAddress('')} + /> + {address && !validAddress ? ( + + + + Invalid address + + + ) : ( + validAddress && + (allowedTranches.length ? ( + + + + Address added to memberlist + + + ) : permissions && !allowedTranches.length ? ( + + + + Address not in memberlist + + + ) : null) + )} + + {pool?.tranches && validAddress && permissions && ( + ( + + {row.currency.name} + + ), + flex: '1', + }, + { + align: 'left', + header: 'Investment', + cell: (row: Token) => , + flex: '1', + }, + { + header: '', + align: 'right', + cell: (row: Token) => { + const isAllowed = allowedTranches.includes(row.id) + + return ( + + ) + }, + flex: '1', + }, + ]} + /> + )} + + + ) +} + +const InvestedCell: React.FC<{ address: string; poolId: string; trancheId: string }> = ({ + poolId, + trancheId, + address, +}) => { + const order = useOrder(poolId, trancheId, address) + const balances = useBalances(address) + const hasBalance = balances && findBalance(balances.tranches, { Tranche: [poolId, trancheId] }) + const hasOrder = order && (order?.submittedAt > 0 || !order.invest.isZero()) + const hasInvested = hasBalance || hasOrder + + return {hasInvested && 'Invested'} +} diff --git a/centrifuge-app/src/pages/IssuerPool/Investors/OnboardingSettings.tsx b/centrifuge-app/src/pages/IssuerPool/Investors/OnboardingSettings.tsx new file mode 100644 index 0000000000..98191f59fa --- /dev/null +++ b/centrifuge-app/src/pages/IssuerPool/Investors/OnboardingSettings.tsx @@ -0,0 +1,401 @@ +import { PoolMetadata, Token } from '@centrifuge/centrifuge-js' +import { useCentrifuge, useCentrifugeTransaction } from '@centrifuge/centrifuge-react' +import { + Box, + Button, + FileUpload, + IconMinusCircle, + SearchInput, + Shelf, + Stack, + Text, + TextInput, +} from '@centrifuge/fabric' +import { Form, FormikProvider, useFormik } from 'formik' +import * as React from 'react' +import { useParams } from 'react-router' +import { lastValueFrom } from 'rxjs' +import styled from 'styled-components' +import { ButtonGroup } from '../../../components/ButtonGroup' +import { PageSection } from '../../../components/PageSection' +import { getFileDataURI } from '../../../utils/getFileDataURI' +import { usePool, usePoolMetadata } from '../../../utils/usePools' +import { KYB_COUNTRY_CODES, KYC_COUNTRY_CODES, RESTRICTED_COUNTRY_CODES } from '../../Onboarding/geographyCodes' + +type OnboardingSettingsInput = { + agreements: { [trancheId: string]: File | string | undefined } + kybRestrictedCountries: string[] + kycRestrictedCountries: string[] + externalOnboardingUrl?: string +} + +export const OnboardingSettings = () => { + const { pid: poolId } = useParams<{ pid: string }>() + const pool = usePool(poolId) + const { data: poolMetadata } = usePoolMetadata(pool) as { data: PoolMetadata } + const [isEditing, setIsEditing] = React.useState(false) + const [useExternalUrl, setUseExternalUrl] = React.useState(!!poolMetadata?.onboarding?.externalOnboardingUrl) + const centrifuge = useCentrifuge() + + const { execute: updateConfigTx, isLoading } = useCentrifugeTransaction( + 'Update pool config', + (cent) => cent.pools.setMetadata, + { + onSuccess: () => { + setIsEditing(false) + }, + } + ) + + const initialValues: OnboardingSettingsInput = React.useMemo(() => { + return { + agreements: (pool.tranches as Token[]).reduce( + (prevT, currT) => ({ + ...prevT, + [currT.id]: poolMetadata?.onboarding?.agreements?.[currT.id]?.uri + ? centrifuge.metadata.parseMetadataUrl(poolMetadata?.onboarding?.agreements[currT.id].uri) + : undefined, + }), + {} + ), + kybRestrictedCountries: + poolMetadata?.onboarding?.kybRestrictedCountries?.map( + (c) => KYB_COUNTRY_CODES[c as keyof typeof KYB_COUNTRY_CODES] + ) ?? [], + kycRestrictedCountries: + poolMetadata?.onboarding?.kycRestrictedCountries?.map( + (c) => KYC_COUNTRY_CODES[c as keyof typeof KYC_COUNTRY_CODES] + ) ?? [], + externalOnboardingUrl: poolMetadata?.onboarding?.externalOnboardingUrl ?? '', + } + }, [pool, poolMetadata]) + + React.useEffect(() => { + if (isEditing) return + formik.resetForm() + formik.setValues(initialValues, false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValues, isEditing]) + + const formik = useFormik({ + initialValues, + validateOnBlur: true, + validateOnChange: false, + validate(values) { + const errors: Partial = {} + if (useExternalUrl && !values.externalOnboardingUrl) { + errors.externalOnboardingUrl = 'Link required for external onboarding' + } + if (useExternalUrl && values.externalOnboardingUrl) { + if (!values.externalOnboardingUrl.includes('http') || !new URL(values.externalOnboardingUrl)) { + errors.externalOnboardingUrl = 'Invalid URL' + } + } + return errors + }, + onSubmit: async (values, actions) => { + if (!values.agreements || !poolMetadata) { + return + } + let onboardingAgreements = poolMetadata?.onboarding?.agreements ?? {} + for (const [tId, file] of Object.entries(values.agreements)) { + if (!file) { + continue + } + // file is already IPFS hash so it hasn't changed + if (typeof file === 'string') { + onboardingAgreements = { + ...onboardingAgreements, + [tId]: { uri: file, mime: 'application/pdf' }, + } + } else { + const uri = await getFileDataURI(file) + const pinnedAgreement = await lastValueFrom(centrifuge.metadata.pinFile(uri)) + onboardingAgreements = { + ...onboardingAgreements, + [tId]: { uri: centrifuge.metadata.parseMetadataUrl(pinnedAgreement.ipfsHash), mime: file.type }, + } + } + } + + const kybRestrictedCountries = values.kybRestrictedCountries + .map((country) => Object.entries(KYB_COUNTRY_CODES).find(([_c, _country]) => _country === country)?.[0] ?? '') + .filter(Boolean) + + const kycRestrictedCountries = values.kycRestrictedCountries + .map((country) => Object.entries(KYC_COUNTRY_CODES).find(([_c, _country]) => _country === country)?.[0] ?? '') + .filter(Boolean) + + const amendedMetadata: PoolMetadata = { + ...poolMetadata, + onboarding: { + agreements: onboardingAgreements, + kycRestrictedCountries, + kybRestrictedCountries, + externalOnboardingUrl: values.externalOnboardingUrl, + }, + } + updateConfigTx([poolId, amendedMetadata]) + actions.setSubmitting(true) + }, + }) + + return ( + +
+ + + + + ) : ( + + ) + } + > + + + Onboarding provider + + setUseExternalUrl(false)} + > + Centrifuge + + setUseExternalUrl(true)} + > + External + + + {useExternalUrl && ( + formik.setFieldValue('externalOnboardingUrl', e.target.value)} + placeholder="https://" + label="External onboarding url" + onBlur={formik.handleBlur} + disabled={!isEditing || formik.isSubmitting || isLoading} + errorMessage={ + formik.errors.externalOnboardingUrl && useExternalUrl + ? formik.errors.externalOnboardingUrl + : undefined + } + /> + )} + + + Subscription documents + {Object.entries(formik.values.agreements).map(([tId, agreement]) => { + return ( + + t.id === tId)?.currency.name + }`} + onFileChange={(file) => { + formik.setFieldValue('agreements', { + ...formik.values.agreements, + [tId]: file, + }) + }} + placeholder="Choose a file..." + disabled={!isEditing || formik.isSubmitting || isLoading} + file={agreement} + accept="application/pdf" + /> + + ) + })} + + + Restricted onboarding countries (KYB) + + { + if ( + Object.values(KYB_COUNTRY_CODES).includes(e.target.value as keyof typeof KYB_COUNTRY_CODES) && + !formik.values.kybRestrictedCountries.includes(e.target.value) + ) { + formik.setFieldValue('kybRestrictedCountries', [ + ...formik.values.kybRestrictedCountries, + e.target.value, + ]) + } + }} + list="kybSupportedCountries" + /> + + {Object.entries(KYB_COUNTRY_CODES).map(([code, country]) => ( + + + {formik.values.kybRestrictedCountries.length > 0 && ( + + KYB restricted countries + + )} + {formik.values.kybRestrictedCountries.map((country) => ( + + + {country} + + - ) - }, - flex: '1', - }, - ]} - /> - )} - -
+ <> + {canEditInvestors && } + {isPoolAdmin && } + ) } - -const InvestedCell: React.FC<{ address: string; poolId: string; trancheId: string }> = ({ - poolId, - trancheId, - address, -}) => { - const order = useOrder(poolId, trancheId, address) - const balances = useBalances(address) - const hasBalance = balances && findBalance(balances.tranches, { Tranche: [poolId, trancheId] }) - const hasOrder = order && (order?.submittedAt > 0 || !order.invest.isZero()) - const hasInvested = hasBalance || hasOrder - - return {hasInvested && 'Invested'} -} diff --git a/centrifuge-app/src/pages/Onboarding/SignSubscriptionAgreement.tsx b/centrifuge-app/src/pages/Onboarding/SignSubscriptionAgreement.tsx index 32cea9ebc5..c89b00f77f 100644 --- a/centrifuge-app/src/pages/Onboarding/SignSubscriptionAgreement.tsx +++ b/centrifuge-app/src/pages/Onboarding/SignSubscriptionAgreement.tsx @@ -48,14 +48,18 @@ export const SignSubscriptionAgreement = ({ signedAgreementUrl, isSignedAgreemen const { mutate: sendDocumentsToIssuer, isLoading: isSending } = useSignAndSendDocuments() const { execute: signRemark, isLoading: isSigningTransaction } = useSignRemark(sendDocumentsToIssuer) - const unsignedAgreementUrl = poolMetadata?.onboarding?.agreements[trancheId] - ? centrifuge.metadata.parseMetadataUrl(poolMetadata?.onboarding?.agreements[trancheId].ipfsHash) + const unsignedAgreementUrl = poolMetadata?.onboarding?.agreements?.[trancheId] + ? centrifuge.metadata.parseMetadataUrl(poolMetadata?.onboarding?.agreements[trancheId].uri) : !poolId.startsWith('0x') ? centrifuge.metadata.parseMetadataUrl(GENERIC_SUBSCRIPTION_AGREEMENT) : null // tinlake pools without subdocs cannot accept investors const isPoolClosedToOnboarding = poolId.startsWith('0x') && !unsignedAgreementUrl + const isCountrySupported = + onboardingUser.investorType === 'entity' + ? !poolMetadata?.onboarding?.kybRestrictedCountries?.includes(onboardingUser.jurisdictionCode) + : !poolMetadata?.onboarding?.kycRestrictedCountries?.includes(onboardingUser.countryOfCitizenship) React.useEffect(() => { if (hasSignedAgreement) { @@ -64,7 +68,7 @@ export const SignSubscriptionAgreement = ({ signedAgreementUrl, isSignedAgreemen // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasSignedAgreement]) - return !isPoolClosedToOnboarding ? ( + return !isPoolClosedToOnboarding && isCountrySupported ? ( + ) : !isCountrySupported ? ( + + + This pool is currently not accepting new investors from your country. Please contact the issuer ( + + {poolMetadata?.pool?.issuer.email} + + ) for any questions. + + } + /> + ) : ( { riskGroups: [], onboarding: { agreements: { - [`${id}-0`]: { ipfsHash: p.metadata?.attributes?.Links?.['Agreements']?.[`${id}-0`] || '' }, - [`${id}-1`]: { ipfsHash: p.metadata?.attributes?.Links?.['Agreements']?.[`${id}-1`] || '' }, + [`${id}-0`]: { + uri: p.metadata?.attributes?.Links?.['Agreements']?.[`${id}-0`] || '', + mime: 'application/pdf', + }, + [`${id}-1`]: { + uri: p.metadata?.attributes?.Links?.['Agreements']?.[`${id}-1`] || '', + mime: 'application/pdf', + }, }, }, } diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index 3aed02b623..8f06e01cb1 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -491,11 +491,15 @@ export type PoolMetadata = { discountRate: string }[] onboarding?: { - agreements: { + agreements?: { [trancheId: string]: { - ipfsHash: string + uri: string + mime: string } } + kybRestrictedCountries?: string[] + kycRestrictedCountries?: string[] + externalOnboardingUrl?: string } } diff --git a/fabric/src/components/RadioButton/index.tsx b/fabric/src/components/RadioButton/index.tsx index 38b311a569..4db428ab9c 100644 --- a/fabric/src/components/RadioButton/index.tsx +++ b/fabric/src/components/RadioButton/index.tsx @@ -8,9 +8,10 @@ import { Text } from '../Text' export type RadioButtonProps = React.InputHTMLAttributes & { label?: string errorMessage?: string + textStyle?: string } -export const RadioButton: React.VFC = ({ label, errorMessage, ...radioProps }) => { +export const RadioButton: React.VFC = ({ label, errorMessage, textStyle, ...radioProps }) => { return (