From 97dc37fad191189ee15f92e44d2ed132817d4fb7 Mon Sep 17 00:00:00 2001 From: Tyler Myracle Date: Tue, 16 Jan 2024 18:29:00 -0600 Subject: [PATCH] more progress on Teller --- apps/server/src/app/app.ts | 2 + apps/server/src/app/routes/index.ts | 1 + apps/server/src/app/routes/teller.router.ts | 45 +++++ .../accounts-manager/AccountTypeSelector.tsx | 33 +--- libs/client/shared/src/api/index.ts | 1 + libs/client/shared/src/api/useTellerApi.ts | 63 ++++++ libs/client/shared/src/hooks/index.ts | 1 + libs/client/shared/src/hooks/useTeller.ts | 180 ++++++++++++++++++ libs/client/shared/src/utils/index.ts | 1 - libs/client/shared/src/utils/teller-utils.ts | 39 ---- .../src/providers/teller/teller.service.ts | 69 ++++++- libs/teller-api/src/teller-api.ts | 21 +- libs/teller-api/src/types/enrollment.ts | 13 ++ libs/teller-api/src/types/index.ts | 1 + package.json | 1 + .../migration.sql | 9 + prisma/schema.prisma | 2 +- 17 files changed, 396 insertions(+), 86 deletions(-) create mode 100644 apps/server/src/app/routes/teller.router.ts create mode 100644 libs/client/shared/src/api/useTellerApi.ts create mode 100644 libs/client/shared/src/hooks/useTeller.ts delete mode 100644 libs/client/shared/src/utils/teller-utils.ts create mode 100644 libs/teller-api/src/types/enrollment.ts create mode 100644 prisma/migrations/20240116224800_add_enrollment_id_for_teller/migration.sql diff --git a/apps/server/src/app/app.ts b/apps/server/src/app/app.ts index c495a30d..06c0870c 100644 --- a/apps/server/src/app/app.ts +++ b/apps/server/src/app/app.ts @@ -36,6 +36,7 @@ import { valuationsRouter, institutionsRouter, finicityRouter, + tellerRouter, transactionsRouter, holdingsRouter, securitiesRouter, @@ -156,6 +157,7 @@ app.use('/v1/users', usersRouter) app.use('/v1/e2e', e2eRouter) app.use('/v1/plaid', plaidRouter) app.use('/v1/finicity', finicityRouter) +app.use('/v1/teller', tellerRouter) app.use('/v1/accounts', accountsRouter) app.use('/v1/account-rollup', accountRollupRouter) app.use('/v1/connections', connectionsRouter) diff --git a/apps/server/src/app/routes/index.ts b/apps/server/src/app/routes/index.ts index 135a9a24..40eb5d16 100644 --- a/apps/server/src/app/routes/index.ts +++ b/apps/server/src/app/routes/index.ts @@ -5,6 +5,7 @@ export { default as usersRouter } from './users.router' export { default as webhooksRouter } from './webhooks.router' export { default as plaidRouter } from './plaid.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 institutionsRouter } from './institutions.router' export { default as transactionsRouter } from './transactions.router' diff --git a/apps/server/src/app/routes/teller.router.ts b/apps/server/src/app/routes/teller.router.ts new file mode 100644 index 00000000..c802ff70 --- /dev/null +++ b/apps/server/src/app/routes/teller.router.ts @@ -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 diff --git a/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx b/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx index d3637783..d3be9221 100644 --- a/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx +++ b/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx @@ -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 maxBy from 'lodash/maxBy' import { @@ -7,14 +7,14 @@ import { useDebounce, usePlaid, useFinicity, + useTellerConfig, + useTellerConnect, } from '@maybe-finance/client/shared' import { Input } from '@maybe-finance/design-system' import InstitutionGrid from './InstitutionGrid' import { AccountTypeGrid } from './AccountTypeGrid' import InstitutionList, { MIN_QUERY_LENGTH } from './InstitutionList' 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 @@ -31,21 +31,16 @@ export default function AccountTypeSelector({ const [searchQuery, setSearchQuery] = useState('') const debouncedSearchQuery = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS) - const [institutionId, setInstitutionId] = useState(undefined) - const showInstitutionList = searchQuery.length >= MIN_QUERY_LENGTH && debouncedSearchQuery.length >= MIN_QUERY_LENGTH && view !== 'manual' - const config = useMemo( - () => BrowserUtil.getTellerConfig(logger, institutionId), - [logger, institutionId] - ) as TellerConnectOptions + const config = useTellerConfig(logger) const { openPlaid } = usePlaid() const { openFinicity } = useFinicity() - const { open: openTeller } = useTellerConnect(config) + const { open: openTeller } = useTellerConnect(config, logger) const inputRef = useRef(null) @@ -55,15 +50,6 @@ export default function AccountTypeSelector({ } }, []) - const configRef = useRef(null) - - useEffect(() => { - if (institutionId) { - configRef.current = BrowserUtil.getTellerConfig(logger, institutionId) - openTeller() - } - }, [institutionId, logger, openTeller]) - return (
{/* Search */} @@ -88,8 +74,6 @@ export default function AccountTypeSelector({ if (!providerInstitution) { alert('No provider found for institution') return - } else { - setInstitutionId(providerInstitution.providerId) } switch (providerInstitution.provider) { @@ -100,7 +84,7 @@ export default function AccountTypeSelector({ openFinicity(providerInstitution.providerId) break case 'TELLER': - openTeller() + openTeller(providerInstitution.providerId) break default: break @@ -163,14 +147,11 @@ export default function AccountTypeSelector({ categoryUser: 'crypto', }, }) - return } if (!data) { return - } else { - setInstitutionId(data.providerId) } switch (data.provider) { @@ -181,7 +162,7 @@ export default function AccountTypeSelector({ openFinicity(data.providerId) break case 'TELLER': - openTeller() + openTeller(data.providerId) break default: break diff --git a/libs/client/shared/src/api/index.ts b/libs/client/shared/src/api/index.ts index 6fe77f31..c03040f8 100644 --- a/libs/client/shared/src/api/index.ts +++ b/libs/client/shared/src/api/index.ts @@ -5,6 +5,7 @@ export * from './useFinicityApi' export * from './useInstitutionApi' export * from './useUserApi' export * from './usePlaidApi' +export * from './useTellerApi' export * from './useValuationApi' export * from './useTransactionApi' export * from './useHoldingApi' diff --git a/libs/client/shared/src/api/useTellerApi.ts b/libs/client/shared/src/api/useTellerApi.ts new file mode 100644 index 00000000..2a323da7 --- /dev/null +++ b/libs/client/shared/src/api/useTellerApi.ts @@ -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( + '/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(['accounts']) + if (!accountsData) + queryClient.setQueryData(['accounts'], { + connections: [{ ...connection, accounts: [] }], + accounts: [], + }) + else { + const { connections, ...rest } = accountsData + queryClient.setQueryData(['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, + } +} diff --git a/libs/client/shared/src/hooks/index.ts b/libs/client/shared/src/hooks/index.ts index 6481beb2..9410b7f4 100644 --- a/libs/client/shared/src/hooks/index.ts +++ b/libs/client/shared/src/hooks/index.ts @@ -9,5 +9,6 @@ export * from './useQueryParam' export * from './useScreenSize' export * from './useAccountNotifications' export * from './usePlaid' +export * from './useTeller' export * from './useProviderStatus' export * from './useModalManager' diff --git a/libs/client/shared/src/hooks/useTeller.ts b/libs/client/shared/src/hooks/useTeller.ts new file mode 100644 index 00000000..b20c056b --- /dev/null +++ b/libs/client/shared/src/hooks/useTeller.ts @@ -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(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, + } +} diff --git a/libs/client/shared/src/utils/index.ts b/libs/client/shared/src/utils/index.ts index 90694d3a..a1ac6e57 100644 --- a/libs/client/shared/src/utils/index.ts +++ b/libs/client/shared/src/utils/index.ts @@ -2,4 +2,3 @@ export * from './image-loaders' export * from './browser-utils' export * from './account-utils' export * from './form-utils' -export * from './teller-utils' diff --git a/libs/client/shared/src/utils/teller-utils.ts b/libs/client/shared/src/utils/teller-utils.ts deleted file mode 100644 index 4265ae84..00000000 --- a/libs/client/shared/src/utils/teller-utils.ts +++ /dev/null @@ -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 -} diff --git a/libs/server/features/src/providers/teller/teller.service.ts b/libs/server/features/src/providers/teller/teller.service.ts index a5847041..931e6721 100644 --- a/libs/server/features/src/providers/teller/teller.service.ts +++ b/libs/server/features/src/providers/teller/teller.service.ts @@ -6,10 +6,11 @@ import type { IAccountConnectionProvider, } from '../../account-connection' import { SharedUtil } from '@maybe-finance/shared' +import type { SharedType } from '@maybe-finance/shared' import type { SyncConnectionOptions, CryptoService, IETL } from '@maybe-finance/server/shared' import _ from 'lodash' 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 { generateConnectUrl(userId: User['id'], institutionId: string): Promise<{ link: string }> @@ -67,13 +68,22 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr async delete(connection: AccountConnection) { // purge teller data - if (connection.tellerAccessToken && connection.tellerAccountId) { - await this.teller.deleteAccount({ - accessToken: this.crypto.decrypt(connection.tellerAccessToken), - accountId: connection.tellerAccountId, + if (connection.tellerAccessToken && connection.tellerEnrollmentId) { + const accounts = await this.prisma.account.findMany({ + where: { accountConnectionId: connection.id }, }) - 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, + 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 + } } diff --git a/libs/teller-api/src/teller-api.ts b/libs/teller-api/src/teller-api.ts index 16b982f7..b6fe48d0 100644 --- a/libs/teller-api/src/teller-api.ts +++ b/libs/teller-api/src/teller-api.ts @@ -137,12 +137,12 @@ export class TellerApi { } private async getApi(accessToken: string): Promise { - const cert = fs.readFileSync('./certs/certificate.pem', 'utf8') - const key = fs.readFileSync('./certs/private_key.pem', 'utf8') + const cert = fs.readFileSync('./certs/certificate.pem') + const key = fs.readFileSync('./certs/private_key.pem') const agent = new https.Agent({ - cert, - key, + cert: cert, + key: key, }) if (!this.api) { @@ -153,15 +153,10 @@ export class TellerApi { headers: { Accept: 'application/json', }, - }) - - this.api.interceptors.request.use((config) => { - // Add the access_token to the auth object - config.auth = { - username: 'ACCESS_TOKEN', - password: accessToken, - } - return config + auth: { + username: accessToken, + password: '', + }, }) } diff --git a/libs/teller-api/src/types/enrollment.ts b/libs/teller-api/src/types/enrollment.ts new file mode 100644 index 00000000..e85b2552 --- /dev/null +++ b/libs/teller-api/src/types/enrollment.ts @@ -0,0 +1,13 @@ +export type Enrollment = { + accessToken: string + user: { + id: string + } + enrollment: { + id: string + institution: { + name: string + } + } + signatures?: string[] +} diff --git a/libs/teller-api/src/types/index.ts b/libs/teller-api/src/types/index.ts index ca90d347..863d6f9e 100644 --- a/libs/teller-api/src/types/index.ts +++ b/libs/teller-api/src/types/index.ts @@ -3,6 +3,7 @@ export * from './account-balance' export * from './account-details' export * from './authentication' export * from './error' +export * from './enrollment' export * from './identity' export * from './institutions' export * from './transactions' diff --git a/package.json b/package.json index 6b62db4a..0ddb5222 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "react-popper": "^2.3.0", "react-ranger": "^2.1.0", "react-responsive": "^9.0.0-beta.10", + "react-script-hook": "^1.7.2", "regenerator-runtime": "0.13.7", "sanitize-html": "^2.8.1", "smooth-scroll-into-view-if-needed": "^1.1.33", diff --git a/prisma/migrations/20240116224800_add_enrollment_id_for_teller/migration.sql b/prisma/migrations/20240116224800_add_enrollment_id_for_teller/migration.sql new file mode 100644 index 00000000..e9dddb32 --- /dev/null +++ b/prisma/migrations/20240116224800_add_enrollment_id_for_teller/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 918f1349..19299720 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,8 +71,8 @@ model AccountConnection { finicityError Json? @map("finicity_error") // teller data - tellerAccountId String? @map("teller_account_id") tellerAccessToken String? @map("teller_access_token") + tellerEnrollmentId String? @map("teller_enrollment_id") tellerInstitutionId String? @map("teller_institution_id") tellerError Json? @map("teller_error")