mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
more progress on Teller
This commit is contained in:
parent
b61fececc7
commit
97dc37fad1
17 changed files with 396 additions and 86 deletions
|
@ -36,6 +36,7 @@ import {
|
||||||
valuationsRouter,
|
valuationsRouter,
|
||||||
institutionsRouter,
|
institutionsRouter,
|
||||||
finicityRouter,
|
finicityRouter,
|
||||||
|
tellerRouter,
|
||||||
transactionsRouter,
|
transactionsRouter,
|
||||||
holdingsRouter,
|
holdingsRouter,
|
||||||
securitiesRouter,
|
securitiesRouter,
|
||||||
|
@ -156,6 +157,7 @@ app.use('/v1/users', usersRouter)
|
||||||
app.use('/v1/e2e', e2eRouter)
|
app.use('/v1/e2e', e2eRouter)
|
||||||
app.use('/v1/plaid', plaidRouter)
|
app.use('/v1/plaid', plaidRouter)
|
||||||
app.use('/v1/finicity', finicityRouter)
|
app.use('/v1/finicity', finicityRouter)
|
||||||
|
app.use('/v1/teller', tellerRouter)
|
||||||
app.use('/v1/accounts', accountsRouter)
|
app.use('/v1/accounts', accountsRouter)
|
||||||
app.use('/v1/account-rollup', accountRollupRouter)
|
app.use('/v1/account-rollup', accountRollupRouter)
|
||||||
app.use('/v1/connections', connectionsRouter)
|
app.use('/v1/connections', connectionsRouter)
|
||||||
|
|
|
@ -5,6 +5,7 @@ export { default as usersRouter } from './users.router'
|
||||||
export { default as webhooksRouter } from './webhooks.router'
|
export { default as webhooksRouter } from './webhooks.router'
|
||||||
export { default as plaidRouter } from './plaid.router'
|
export { default as plaidRouter } from './plaid.router'
|
||||||
export { default as finicityRouter } from './finicity.router'
|
export { default as finicityRouter } from './finicity.router'
|
||||||
|
export { default as tellerRouter } from './teller.router'
|
||||||
export { default as valuationsRouter } from './valuations.router'
|
export { default as valuationsRouter } from './valuations.router'
|
||||||
export { default as institutionsRouter } from './institutions.router'
|
export { default as institutionsRouter } from './institutions.router'
|
||||||
export { default as transactionsRouter } from './transactions.router'
|
export { default as transactionsRouter } from './transactions.router'
|
||||||
|
|
45
apps/server/src/app/routes/teller.router.ts
Normal file
45
apps/server/src/app/routes/teller.router.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import { Router } from 'express'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import endpoint from '../lib/endpoint'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/handle-enrollment',
|
||||||
|
endpoint.create({
|
||||||
|
input: z.object({
|
||||||
|
institution: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
enrollment: z.object({
|
||||||
|
accessToken: z.string(),
|
||||||
|
user: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
enrollment: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
institution: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
signatures: z.array(z.string()).optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
resolve: ({ input: { institution, enrollment }, ctx }) => {
|
||||||
|
return ctx.tellerService.handleEnrollment(ctx.user!.id, institution, enrollment)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/institutions/sync',
|
||||||
|
endpoint.create({
|
||||||
|
resolve: async ({ ctx }) => {
|
||||||
|
ctx.ability.throwUnlessCan('manage', 'Institution')
|
||||||
|
await ctx.queueService.getQueue('sync-institution').add('sync-teller-institutions', {})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { RiFolderLine, RiHandCoinLine, RiLockLine, RiSearchLine } from 'react-icons/ri'
|
import { RiFolderLine, RiHandCoinLine, RiLockLine, RiSearchLine } from 'react-icons/ri'
|
||||||
import maxBy from 'lodash/maxBy'
|
import maxBy from 'lodash/maxBy'
|
||||||
import {
|
import {
|
||||||
|
@ -7,14 +7,14 @@ import {
|
||||||
useDebounce,
|
useDebounce,
|
||||||
usePlaid,
|
usePlaid,
|
||||||
useFinicity,
|
useFinicity,
|
||||||
|
useTellerConfig,
|
||||||
|
useTellerConnect,
|
||||||
} from '@maybe-finance/client/shared'
|
} from '@maybe-finance/client/shared'
|
||||||
import { Input } from '@maybe-finance/design-system'
|
import { Input } from '@maybe-finance/design-system'
|
||||||
import InstitutionGrid from './InstitutionGrid'
|
import InstitutionGrid from './InstitutionGrid'
|
||||||
import { AccountTypeGrid } from './AccountTypeGrid'
|
import { AccountTypeGrid } from './AccountTypeGrid'
|
||||||
import InstitutionList, { MIN_QUERY_LENGTH } from './InstitutionList'
|
import InstitutionList, { MIN_QUERY_LENGTH } from './InstitutionList'
|
||||||
import { useLogger } from '@maybe-finance/client/shared'
|
import { useLogger } from '@maybe-finance/client/shared'
|
||||||
import { BrowserUtil } from '@maybe-finance/client/shared'
|
|
||||||
import { useTellerConnect, type TellerConnectOptions } from 'teller-connect-react'
|
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 300
|
const SEARCH_DEBOUNCE_MS = 300
|
||||||
|
|
||||||
|
@ -31,21 +31,16 @@ export default function AccountTypeSelector({
|
||||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||||
const debouncedSearchQuery = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS)
|
const debouncedSearchQuery = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS)
|
||||||
|
|
||||||
const [institutionId, setInstitutionId] = useState<string | undefined>(undefined)
|
|
||||||
|
|
||||||
const showInstitutionList =
|
const showInstitutionList =
|
||||||
searchQuery.length >= MIN_QUERY_LENGTH &&
|
searchQuery.length >= MIN_QUERY_LENGTH &&
|
||||||
debouncedSearchQuery.length >= MIN_QUERY_LENGTH &&
|
debouncedSearchQuery.length >= MIN_QUERY_LENGTH &&
|
||||||
view !== 'manual'
|
view !== 'manual'
|
||||||
|
|
||||||
const config = useMemo(
|
const config = useTellerConfig(logger)
|
||||||
() => BrowserUtil.getTellerConfig(logger, institutionId),
|
|
||||||
[logger, institutionId]
|
|
||||||
) as TellerConnectOptions
|
|
||||||
|
|
||||||
const { openPlaid } = usePlaid()
|
const { openPlaid } = usePlaid()
|
||||||
const { openFinicity } = useFinicity()
|
const { openFinicity } = useFinicity()
|
||||||
const { open: openTeller } = useTellerConnect(config)
|
const { open: openTeller } = useTellerConnect(config, logger)
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
@ -55,15 +50,6 @@ export default function AccountTypeSelector({
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const configRef = useRef<TellerConnectOptions | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (institutionId) {
|
|
||||||
configRef.current = BrowserUtil.getTellerConfig(logger, institutionId)
|
|
||||||
openTeller()
|
|
||||||
}
|
|
||||||
}, [institutionId, logger, openTeller])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
|
@ -88,8 +74,6 @@ export default function AccountTypeSelector({
|
||||||
if (!providerInstitution) {
|
if (!providerInstitution) {
|
||||||
alert('No provider found for institution')
|
alert('No provider found for institution')
|
||||||
return
|
return
|
||||||
} else {
|
|
||||||
setInstitutionId(providerInstitution.providerId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (providerInstitution.provider) {
|
switch (providerInstitution.provider) {
|
||||||
|
@ -100,7 +84,7 @@ export default function AccountTypeSelector({
|
||||||
openFinicity(providerInstitution.providerId)
|
openFinicity(providerInstitution.providerId)
|
||||||
break
|
break
|
||||||
case 'TELLER':
|
case 'TELLER':
|
||||||
openTeller()
|
openTeller(providerInstitution.providerId)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
@ -163,14 +147,11 @@ export default function AccountTypeSelector({
|
||||||
categoryUser: 'crypto',
|
categoryUser: 'crypto',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return
|
return
|
||||||
} else {
|
|
||||||
setInstitutionId(data.providerId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (data.provider) {
|
switch (data.provider) {
|
||||||
|
@ -181,7 +162,7 @@ export default function AccountTypeSelector({
|
||||||
openFinicity(data.providerId)
|
openFinicity(data.providerId)
|
||||||
break
|
break
|
||||||
case 'TELLER':
|
case 'TELLER':
|
||||||
openTeller()
|
openTeller(data.providerId)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
|
|
@ -5,6 +5,7 @@ export * from './useFinicityApi'
|
||||||
export * from './useInstitutionApi'
|
export * from './useInstitutionApi'
|
||||||
export * from './useUserApi'
|
export * from './useUserApi'
|
||||||
export * from './usePlaidApi'
|
export * from './usePlaidApi'
|
||||||
|
export * from './useTellerApi'
|
||||||
export * from './useValuationApi'
|
export * from './useValuationApi'
|
||||||
export * from './useTransactionApi'
|
export * from './useTransactionApi'
|
||||||
export * from './useHoldingApi'
|
export * from './useHoldingApi'
|
||||||
|
|
63
libs/client/shared/src/api/useTellerApi.ts
Normal file
63
libs/client/shared/src/api/useTellerApi.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
|
import { invalidateAccountQueries } from '../utils'
|
||||||
|
import type { AxiosInstance } from 'axios'
|
||||||
|
import type { TellerTypes } from '@maybe-finance/teller-api'
|
||||||
|
|
||||||
|
type TellerInstitution = {
|
||||||
|
name: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TellerApi = (axios: AxiosInstance) => ({
|
||||||
|
async handleEnrollment(input: {
|
||||||
|
institution: TellerInstitution
|
||||||
|
enrollment: TellerTypes.Enrollment
|
||||||
|
}) {
|
||||||
|
const { data } = await axios.post<SharedType.AccountConnection>(
|
||||||
|
'/teller/handle-enrollment',
|
||||||
|
input
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useTellerApi() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { axios } = useAxiosWithAuth()
|
||||||
|
const api = useMemo(() => TellerApi(axios), [axios])
|
||||||
|
|
||||||
|
const addConnectionToState = (connection: SharedType.AccountConnection) => {
|
||||||
|
const accountsData = queryClient.getQueryData<SharedType.AccountsResponse>(['accounts'])
|
||||||
|
if (!accountsData)
|
||||||
|
queryClient.setQueryData<SharedType.AccountsResponse>(['accounts'], {
|
||||||
|
connections: [{ ...connection, accounts: [] }],
|
||||||
|
accounts: [],
|
||||||
|
})
|
||||||
|
else {
|
||||||
|
const { connections, ...rest } = accountsData
|
||||||
|
queryClient.setQueryData<SharedType.AccountsResponse>(['accounts'], {
|
||||||
|
connections: [...connections, { ...connection, accounts: [] }],
|
||||||
|
...rest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useHandleEnrollment = () =>
|
||||||
|
useMutation(api.handleEnrollment, {
|
||||||
|
onSuccess: (_connection) => {
|
||||||
|
addConnectionToState(_connection)
|
||||||
|
toast.success(`Account connection added!`)
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
invalidateAccountQueries(queryClient, false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
useHandleEnrollment,
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,5 +9,6 @@ export * from './useQueryParam'
|
||||||
export * from './useScreenSize'
|
export * from './useScreenSize'
|
||||||
export * from './useAccountNotifications'
|
export * from './useAccountNotifications'
|
||||||
export * from './usePlaid'
|
export * from './usePlaid'
|
||||||
|
export * from './useTeller'
|
||||||
export * from './useProviderStatus'
|
export * from './useProviderStatus'
|
||||||
export * from './useModalManager'
|
export * from './useModalManager'
|
||||||
|
|
180
libs/client/shared/src/hooks/useTeller.ts
Normal file
180
libs/client/shared/src/hooks/useTeller.ts
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import * as Sentry from '@sentry/react'
|
||||||
|
import type { Logger } from '../providers/LogProvider'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { useTellerApi } from '../api'
|
||||||
|
import type {
|
||||||
|
TellerConnectEnrollment,
|
||||||
|
TellerConnectFailure,
|
||||||
|
TellerConnectOptions,
|
||||||
|
TellerConnectInstance,
|
||||||
|
} from 'teller-connect-react'
|
||||||
|
import useScript from 'react-script-hook'
|
||||||
|
type TellerEnvironment = 'sandbox' | 'development' | 'production' | undefined
|
||||||
|
type TellerAccountSelection = 'disabled' | 'single' | 'multiple' | undefined
|
||||||
|
const TC_JS = 'https://cdn.teller.io/connect/connect.js'
|
||||||
|
|
||||||
|
// Create the base configuration for Teller Connect
|
||||||
|
export const useTellerConfig = (logger: Logger) => {
|
||||||
|
return {
|
||||||
|
applicationId: process.env.NEXT_PUBLIC_TELLER_APP_ID ?? 'ADD_TELLER_APP_ID',
|
||||||
|
environment: (process.env.NEXT_PUBLIC_TELLER_ENV as TellerEnvironment) ?? 'sandbox',
|
||||||
|
selectAccount: 'disabled' as TellerAccountSelection,
|
||||||
|
onInit: () => {
|
||||||
|
logger.debug(`Teller Connect has initialized`)
|
||||||
|
},
|
||||||
|
onSuccess: {},
|
||||||
|
onExit: () => {
|
||||||
|
logger.debug(`Teller Connect exited`)
|
||||||
|
},
|
||||||
|
onFailure: (failure: TellerConnectFailure) => {
|
||||||
|
logger.error(`Teller Connect exited with error`, failure)
|
||||||
|
Sentry.captureEvent({
|
||||||
|
level: 'error',
|
||||||
|
message: 'TELLER_CONNECT_ERROR',
|
||||||
|
tags: {
|
||||||
|
'teller.error.code': failure.code,
|
||||||
|
'teller.error.message': failure.message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} as TellerConnectOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom implementation of useTellerHook to handle institution id being passed in
|
||||||
|
export const useTellerConnect = (options: TellerConnectOptions, logger: Logger) => {
|
||||||
|
const { useHandleEnrollment } = useTellerApi()
|
||||||
|
const handleEnrollment = useHandleEnrollment()
|
||||||
|
const [loading, error] = useScript({
|
||||||
|
src: TC_JS,
|
||||||
|
checkForExisting: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [teller, setTeller] = useState<TellerConnectInstance | null>(null)
|
||||||
|
const [iframeLoaded, setIframeLoaded] = useState(false)
|
||||||
|
|
||||||
|
const createTellerInstance = (institutionId: string) => {
|
||||||
|
return createTeller(
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
onSuccess: async (enrollment: TellerConnectEnrollment) => {
|
||||||
|
logger.debug(`User enrolled successfully`, enrollment)
|
||||||
|
try {
|
||||||
|
await handleEnrollment.mutateAsync({
|
||||||
|
institution: {
|
||||||
|
id: institutionId!,
|
||||||
|
name: enrollment.enrollment.institution.name,
|
||||||
|
},
|
||||||
|
enrollment,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`Failed to add account`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
institution: institutionId,
|
||||||
|
onInit: () => {
|
||||||
|
setIframeLoaded(true)
|
||||||
|
options.onInit && options.onInit()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
window.TellerConnect.setup
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.applicationId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !window.TellerConnect) {
|
||||||
|
console.error('Error loading TellerConnect:', error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (teller != null) {
|
||||||
|
teller.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => teller?.destroy()
|
||||||
|
}, [
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
options.applicationId,
|
||||||
|
options.enrollmentId,
|
||||||
|
options.connectToken,
|
||||||
|
options.products,
|
||||||
|
])
|
||||||
|
|
||||||
|
const ready = teller != null && (!loading || iframeLoaded)
|
||||||
|
|
||||||
|
const logIt = () => {
|
||||||
|
if (!options.applicationId) {
|
||||||
|
console.error('teller-connect-react: open() called without a valid applicationId.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error,
|
||||||
|
ready,
|
||||||
|
open: (institutionId: string) => {
|
||||||
|
logIt()
|
||||||
|
const tellerInstance = createTellerInstance(institutionId)
|
||||||
|
tellerInstance.open()
|
||||||
|
setTeller(tellerInstance)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManagerState {
|
||||||
|
teller: TellerConnectInstance | null
|
||||||
|
open: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTeller = (
|
||||||
|
config: TellerConnectOptions,
|
||||||
|
creator: (config: TellerConnectOptions) => TellerConnectInstance
|
||||||
|
) => {
|
||||||
|
const state: ManagerState = {
|
||||||
|
teller: null,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window === 'undefined' || !window.TellerConnect) {
|
||||||
|
throw new Error('TellerConnect is not loaded')
|
||||||
|
}
|
||||||
|
|
||||||
|
state.teller = creator({
|
||||||
|
...config,
|
||||||
|
onExit: () => {
|
||||||
|
state.open = false
|
||||||
|
config.onExit && config.onExit()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
if (!state.teller) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.open = true
|
||||||
|
state.teller.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
if (!state.teller) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.teller.destroy()
|
||||||
|
state.teller = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
destroy,
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,4 +2,3 @@ export * from './image-loaders'
|
||||||
export * from './browser-utils'
|
export * from './browser-utils'
|
||||||
export * from './account-utils'
|
export * from './account-utils'
|
||||||
export * from './form-utils'
|
export * from './form-utils'
|
||||||
export * from './teller-utils'
|
|
||||||
|
|
|
@ -1,39 +0,0 @@
|
||||||
import * as Sentry from '@sentry/react'
|
|
||||||
import type { Logger } from '../providers/LogProvider'
|
|
||||||
import type {
|
|
||||||
TellerConnectEnrollment,
|
|
||||||
TellerConnectFailure,
|
|
||||||
TellerConnectOptions,
|
|
||||||
} from 'teller-connect-react'
|
|
||||||
|
|
||||||
type TellerEnvironment = 'sandbox' | 'development' | 'production' | undefined
|
|
||||||
type TellerAccountSelection = 'disabled' | 'single' | 'multiple' | undefined
|
|
||||||
|
|
||||||
export const getTellerConfig = (logger: Logger, institutionId: string | undefined) => {
|
|
||||||
return {
|
|
||||||
applicationId: process.env.NEXT_PUBLIC_TELLER_APP_ID ?? 'ADD_TELLER_APP_ID',
|
|
||||||
environment: (process.env.NEXT_PUBLIC_TELLER_ENV as TellerEnvironment) ?? 'sandbox',
|
|
||||||
selectAccount: 'disabled' as TellerAccountSelection,
|
|
||||||
...(institutionId !== undefined ? { institution: institutionId } : {}),
|
|
||||||
onInit: () => {
|
|
||||||
logger.debug(`Teller Connect has initialized`)
|
|
||||||
},
|
|
||||||
onSuccess: (enrollment: TellerConnectEnrollment) => {
|
|
||||||
logger.debug(`User enrolled successfully`, enrollment)
|
|
||||||
},
|
|
||||||
onExit: () => {
|
|
||||||
logger.debug(`Teller Connect exited`)
|
|
||||||
},
|
|
||||||
onFailure: (failure: TellerConnectFailure) => {
|
|
||||||
logger.error(`Teller Connect exited with error`, failure)
|
|
||||||
Sentry.captureEvent({
|
|
||||||
level: 'error',
|
|
||||||
message: 'TELLER_CONNECT_ERROR',
|
|
||||||
tags: {
|
|
||||||
'teller.error.code': failure.code,
|
|
||||||
'teller.error.message': failure.message,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
} as TellerConnectOptions
|
|
||||||
}
|
|
|
@ -6,10 +6,11 @@ import type {
|
||||||
IAccountConnectionProvider,
|
IAccountConnectionProvider,
|
||||||
} from '../../account-connection'
|
} from '../../account-connection'
|
||||||
import { SharedUtil } from '@maybe-finance/shared'
|
import { SharedUtil } from '@maybe-finance/shared'
|
||||||
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
import type { SyncConnectionOptions, CryptoService, IETL } from '@maybe-finance/server/shared'
|
import type { SyncConnectionOptions, CryptoService, IETL } from '@maybe-finance/server/shared'
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
import { ErrorUtil, etl } from '@maybe-finance/server/shared'
|
import { ErrorUtil, etl } from '@maybe-finance/server/shared'
|
||||||
import type { TellerApi } from '@maybe-finance/teller-api'
|
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
|
||||||
|
|
||||||
export interface ITellerConnect {
|
export interface ITellerConnect {
|
||||||
generateConnectUrl(userId: User['id'], institutionId: string): Promise<{ link: string }>
|
generateConnectUrl(userId: User['id'], institutionId: string): Promise<{ link: string }>
|
||||||
|
@ -67,13 +68,22 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
|
||||||
|
|
||||||
async delete(connection: AccountConnection) {
|
async delete(connection: AccountConnection) {
|
||||||
// purge teller data
|
// purge teller data
|
||||||
if (connection.tellerAccessToken && connection.tellerAccountId) {
|
if (connection.tellerAccessToken && connection.tellerEnrollmentId) {
|
||||||
await this.teller.deleteAccount({
|
const accounts = await this.prisma.account.findMany({
|
||||||
accessToken: this.crypto.decrypt(connection.tellerAccessToken),
|
where: { accountConnectionId: connection.id },
|
||||||
accountId: connection.tellerAccountId,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.logger.info(`Item ${connection.tellerAccountId} removed`)
|
for (const account of accounts) {
|
||||||
|
if (!account.tellerAccountId) continue
|
||||||
|
await this.teller.deleteAccount({
|
||||||
|
accessToken: this.crypto.decrypt(connection.tellerAccessToken),
|
||||||
|
accountId: account.tellerAccountId,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.logger.info(`Teller account ${account.id} removed`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(`Teller enrollment ${connection.tellerEnrollmentId} removed`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,4 +134,51 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleEnrollment(
|
||||||
|
userId: User['id'],
|
||||||
|
institution: Pick<TellerTypes.Institution, 'name' | 'id'>,
|
||||||
|
enrollment: TellerTypes.Enrollment
|
||||||
|
) {
|
||||||
|
const connections = await this.prisma.accountConnection.findMany({
|
||||||
|
where: { userId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (connections.length > 40) {
|
||||||
|
throw new Error('MAX_ACCOUNT_CONNECTIONS')
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await this.teller.getAccounts({ accessToken: enrollment.accessToken })
|
||||||
|
|
||||||
|
this.logger.info(`Teller accounts retrieved for enrollment ${enrollment.enrollment.id}`)
|
||||||
|
|
||||||
|
// If all the accounts are Non-USD, throw an error
|
||||||
|
if (accounts.every((a) => a.currency !== 'USD')) {
|
||||||
|
throw new Error('USD_ONLY')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create account connection on exchange; accounts + txns will sync later with webhook
|
||||||
|
const [accountConnection] = await this.prisma.$transaction([
|
||||||
|
this.prisma.accountConnection.create({
|
||||||
|
data: {
|
||||||
|
name: enrollment.enrollment.institution.name,
|
||||||
|
type: 'teller' as SharedType.AccountConnectionType,
|
||||||
|
tellerEnrollmentId: enrollment.enrollment.id,
|
||||||
|
tellerInstitutionId: institution.id,
|
||||||
|
tellerAccessToken: this.crypto.encrypt(enrollment.accessToken),
|
||||||
|
userId,
|
||||||
|
syncStatus: 'PENDING',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
tellerUserId: enrollment.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return accountConnection
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -137,12 +137,12 @@ export class TellerApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getApi(accessToken: string): Promise<AxiosInstance> {
|
private async getApi(accessToken: string): Promise<AxiosInstance> {
|
||||||
const cert = fs.readFileSync('./certs/certificate.pem', 'utf8')
|
const cert = fs.readFileSync('./certs/certificate.pem')
|
||||||
const key = fs.readFileSync('./certs/private_key.pem', 'utf8')
|
const key = fs.readFileSync('./certs/private_key.pem')
|
||||||
|
|
||||||
const agent = new https.Agent({
|
const agent = new https.Agent({
|
||||||
cert,
|
cert: cert,
|
||||||
key,
|
key: key,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!this.api) {
|
if (!this.api) {
|
||||||
|
@ -153,15 +153,10 @@ export class TellerApi {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
})
|
auth: {
|
||||||
|
username: accessToken,
|
||||||
this.api.interceptors.request.use((config) => {
|
password: '',
|
||||||
// Add the access_token to the auth object
|
},
|
||||||
config.auth = {
|
|
||||||
username: 'ACCESS_TOKEN',
|
|
||||||
password: accessToken,
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
13
libs/teller-api/src/types/enrollment.ts
Normal file
13
libs/teller-api/src/types/enrollment.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
export type Enrollment = {
|
||||||
|
accessToken: string
|
||||||
|
user: {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
enrollment: {
|
||||||
|
id: string
|
||||||
|
institution: {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signatures?: string[]
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ export * from './account-balance'
|
||||||
export * from './account-details'
|
export * from './account-details'
|
||||||
export * from './authentication'
|
export * from './authentication'
|
||||||
export * from './error'
|
export * from './error'
|
||||||
|
export * from './enrollment'
|
||||||
export * from './identity'
|
export * from './identity'
|
||||||
export * from './institutions'
|
export * from './institutions'
|
||||||
export * from './transactions'
|
export * from './transactions'
|
||||||
|
|
|
@ -147,6 +147,7 @@
|
||||||
"react-popper": "^2.3.0",
|
"react-popper": "^2.3.0",
|
||||||
"react-ranger": "^2.1.0",
|
"react-ranger": "^2.1.0",
|
||||||
"react-responsive": "^9.0.0-beta.10",
|
"react-responsive": "^9.0.0-beta.10",
|
||||||
|
"react-script-hook": "^1.7.2",
|
||||||
"regenerator-runtime": "0.13.7",
|
"regenerator-runtime": "0.13.7",
|
||||||
"sanitize-html": "^2.8.1",
|
"sanitize-html": "^2.8.1",
|
||||||
"smooth-scroll-into-view-if-needed": "^1.1.33",
|
"smooth-scroll-into-view-if-needed": "^1.1.33",
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `teller_account_id` on the `account_connection` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "account_connection" DROP COLUMN "teller_account_id",
|
||||||
|
ADD COLUMN "teller_enrollment_id" TEXT;
|
|
@ -71,8 +71,8 @@ model AccountConnection {
|
||||||
finicityError Json? @map("finicity_error")
|
finicityError Json? @map("finicity_error")
|
||||||
|
|
||||||
// teller data
|
// teller data
|
||||||
tellerAccountId String? @map("teller_account_id")
|
|
||||||
tellerAccessToken String? @map("teller_access_token")
|
tellerAccessToken String? @map("teller_access_token")
|
||||||
|
tellerEnrollmentId String? @map("teller_enrollment_id")
|
||||||
tellerInstitutionId String? @map("teller_institution_id")
|
tellerInstitutionId String? @map("teller_institution_id")
|
||||||
tellerError Json? @map("teller_error")
|
tellerError Json? @map("teller_error")
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue