1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 15:35:22 +02:00

more progress on Teller

This commit is contained in:
Tyler Myracle 2024-01-16 18:29:00 -06:00
parent b61fececc7
commit 97dc37fad1
17 changed files with 396 additions and 86 deletions

View file

@ -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)

View file

@ -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'

View 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

View file

@ -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

View file

@ -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'

View 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,
}
}

View file

@ -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'

View 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,
}
}

View file

@ -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'

View file

@ -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
}

View file

@ -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
}
} }

View file

@ -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
}) })
} }

View file

@ -0,0 +1,13 @@
export type Enrollment = {
accessToken: string
user: {
id: string
}
enrollment: {
id: string
institution: {
name: string
}
}
signatures?: string[]
}

View file

@ -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'

View file

@ -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",

View file

@ -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;

View file

@ -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")