diff --git a/.env.example b/.env.example index abd0167b..8cb427a1 100644 --- a/.env.example +++ b/.env.example @@ -35,7 +35,8 @@ NX_POLYGON_API_KEY= # We use Teller.io for automated banking data. You can sign up for a free # account and get a free API key at https://teller.io NX_TELLER_SIGNING_SECRET= -NX_TELLER_APP_ID= +NEXT_PUBLIC_TELLER_APP_ID= +NEXT_PUBLIC_TELLER_ENV=sandbox NX_TELLER_ENV=sandbox ######################################################################## @@ -57,4 +58,4 @@ NX_POSTMARK_API_TOKEN= ######################################################################## NX_PLAID_SECRET= NX_FINICITY_APP_KEY= -NX_FINICITY_PARTNER_SECRET= \ No newline at end of file +NX_FINICITY_PARTNER_SECRET= diff --git a/README.md b/README.md index a3c8eca8..41debdf2 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,14 @@ Then, create a new secret using `openssl rand -base64 32` and populate `NEXTAUTH To enable transactional emails, you'll need to create a [Postmark](https://postmarkapp.com/) account and add your API key to your `.env` file (`NX_POSTMARK_API_TOKEN`). You can also set the from and reply-to email addresses (`NX_POSTMARK_FROM_ADDRESS` and `NX_POSTMARK_REPLY_TO_ADDRESS`). If you want to run the app without email, you can set `NX_POSTMARK_API_TOKEN` to a dummy value. +Maybe uses [Teller](https://teller.io/) for connecting financial accounts. To get started with Teller, you'll need to create an account. Once you've created an account: + +- Add your Teller application id to your `.env` file (`NEXT_PUBLIC_TELLER_APP_ID`). +- Download your authentication certificates from Teller, create a `certs` folder in the root of the project, and place your certs in that directory. You should have both a `certificate.pem` and `private_key.pem`. **NEVER** check these files into source control, the `.gitignore` file will prevent the `certs/` directory from being added, but please double check. +- Set your `NEXT_PUBLIC_TELLER_ENV` and `NX_TELLER_ENV` to your desired environment. The default is `sandbox` which allows for testing with mock data. The login credentials for the sandbox environment are `username` and `password`. To connect to real financial accounts, you'll need to use the `development` environment. +- Webhooks are not implemented yet, but you can populate the `NX_TELLER_SIGNING_SECRET` with the value from your Teller account. +- We highly recommend checking out the [Teller docs](https://teller.io/docs) for more info. + Then run the following yarn commands: ``` 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/lib/endpoint.ts b/apps/server/src/app/lib/endpoint.ts index 8e01b00d..f6340672 100644 --- a/apps/server/src/app/lib/endpoint.ts +++ b/apps/server/src/app/lib/endpoint.ts @@ -240,6 +240,7 @@ const userService = new UserService( const institutionProviderFactory = new InstitutionProviderFactory({ PLAID: plaidService, FINICITY: finicityService, + TELLER: tellerService, }) const institutionService: IInstitutionService = new InstitutionService( 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/apps/workers/src/app/__tests__/helpers/user.test-helper.ts b/apps/workers/src/app/__tests__/helpers/user.test-helper.ts index 0956a22b..322b7dba 100644 --- a/apps/workers/src/app/__tests__/helpers/user.test-helper.ts +++ b/apps/workers/src/app/__tests__/helpers/user.test-helper.ts @@ -1,22 +1,28 @@ import type { PrismaClient, User } from '@prisma/client' +import { faker } from '@faker-js/faker' -export async function resetUser(prisma: PrismaClient, authId = 'TODO'): Promise { - // eslint-disable-next-line - const [_, __, ___, user] = await prisma.$transaction([ - prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${authId};`, +export async function resetUser(prisma: PrismaClient, authId = '__TEST_USER_ID__'): Promise { + try { + // eslint-disable-next-line + const [_, __, ___, user] = await prisma.$transaction([ + prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${authId};`, - // Deleting a user does not cascade to securities, so delete all security records - prisma.$executeRaw`DELETE from security;`, - prisma.$executeRaw`DELETE from security_pricing;`, + // Deleting a user does not cascade to securities, so delete all security records + prisma.$executeRaw`DELETE from security;`, + prisma.$executeRaw`DELETE from security_pricing;`, - prisma.user.create({ - data: { - authId, - email: 'test@example.com', - finicityCustomerId: 'TEST', - }, - }), - ]) - - return user + prisma.user.create({ + data: { + authId, + email: faker.internet.email(), + finicityCustomerId: faker.string.uuid(), + tellerUserId: faker.string.uuid(), + }, + }), + ]) + return user + } catch (e) { + console.error('error in reset user transaction', e) + throw e + } } diff --git a/apps/workers/src/app/__tests__/teller.integration.spec.ts b/apps/workers/src/app/__tests__/teller.integration.spec.ts new file mode 100644 index 00000000..74c19457 --- /dev/null +++ b/apps/workers/src/app/__tests__/teller.integration.spec.ts @@ -0,0 +1,118 @@ +import type { User } from '@prisma/client' +import { TellerGenerator } from '../../../../../tools/generators' +import { TellerApi } from '@maybe-finance/teller-api' +jest.mock('@maybe-finance/teller-api') +import { + TellerETL, + TellerService, + type IAccountConnectionProvider, +} from '@maybe-finance/server/features' +import { createLogger } from '@maybe-finance/server/shared' +import prisma from '../lib/prisma' +import { resetUser } from './helpers/user.test-helper' +import { transports } from 'winston' +import { cryptoService } from '../lib/di' + +const logger = createLogger({ level: 'debug', transports: [new transports.Console()] }) +const teller = jest.mocked(new TellerApi()) +const tellerETL = new TellerETL(logger, prisma, teller, cryptoService) +const service: IAccountConnectionProvider = new TellerService( + logger, + prisma, + teller, + tellerETL, + cryptoService, + 'TELLER_WEBHOOK_URL', + true +) + +afterAll(async () => { + await prisma.$disconnect() +}) + +describe('Teller', () => { + let user: User + + beforeEach(async () => { + jest.clearAllMocks() + + user = await resetUser(prisma) + }) + + it('syncs connection', async () => { + const tellerConnection = TellerGenerator.generateConnection() + const tellerAccounts = tellerConnection.accountsWithBalances + const tellerTransactions = tellerConnection.transactions + + teller.getAccounts.mockResolvedValue(tellerAccounts) + + teller.getTransactions.mockImplementation(async ({ accountId }) => { + return Promise.resolve(tellerTransactions.filter((t) => t.account_id === accountId)) + }) + + const connection = await prisma.accountConnection.create({ + data: { + userId: user.id, + name: 'TEST_TELLER', + type: 'teller', + tellerEnrollmentId: tellerConnection.enrollment.enrollment.id, + tellerInstitutionId: tellerConnection.enrollment.institutionId, + tellerAccessToken: cryptoService.encrypt(tellerConnection.enrollment.accessToken), + }, + }) + + await service.sync(connection) + + const { accounts } = await prisma.accountConnection.findUniqueOrThrow({ + where: { + id: connection.id, + }, + include: { + accounts: { + include: { + transactions: true, + investmentTransactions: true, + holdings: true, + valuations: true, + }, + }, + }, + }) + + // all accounts + expect(accounts).toHaveLength(tellerConnection.accounts.length) + for (const account of accounts) { + expect(account.transactions).toHaveLength( + tellerTransactions.filter((t) => t.account_id === account.tellerAccountId).length + ) + } + + // credit accounts + const creditAccounts = tellerAccounts.filter((a) => a.type === 'credit') + expect(accounts.filter((a) => a.type === 'CREDIT')).toHaveLength(creditAccounts.length) + for (const creditAccount of creditAccounts) { + const account = accounts.find((a) => a.tellerAccountId === creditAccount.id)! + expect(account.transactions).toHaveLength( + tellerTransactions.filter((t) => t.account_id === account.tellerAccountId).length + ) + expect(account.holdings).toHaveLength(0) + expect(account.valuations).toHaveLength(0) + expect(account.investmentTransactions).toHaveLength(0) + } + + // depository accounts + const depositoryAccounts = tellerAccounts.filter((a) => a.type === 'depository') + expect(accounts.filter((a) => a.type === 'DEPOSITORY')).toHaveLength( + depositoryAccounts.length + ) + for (const depositoryAccount of depositoryAccounts) { + const account = accounts.find((a) => a.tellerAccountId === depositoryAccount.id)! + expect(account.transactions).toHaveLength( + tellerTransactions.filter((t) => t.account_id === account.tellerAccountId).length + ) + expect(account.holdings).toHaveLength(0) + expect(account.valuations).toHaveLength(0) + expect(account.investmentTransactions).toHaveLength(0) + } + }) +}) diff --git a/apps/workers/src/app/lib/di.ts b/apps/workers/src/app/lib/di.ts index 987ebedd..9e6d68c5 100644 --- a/apps/workers/src/app/lib/di.ts +++ b/apps/workers/src/app/lib/di.ts @@ -259,6 +259,7 @@ export const securityPricingProcessor: ISecurityPricingProcessor = new SecurityP const institutionProviderFactory = new InstitutionProviderFactory({ PLAID: plaidService, FINICITY: finicityService, + TELLER: tellerService, }) export const institutionService: IInstitutionService = new InstitutionService( diff --git a/apps/workers/src/main.ts b/apps/workers/src/main.ts index 35c16cdd..a0cf5e82 100644 --- a/apps/workers/src/main.ts +++ b/apps/workers/src/main.ts @@ -115,6 +115,11 @@ syncInstitutionQueue.process( async () => await institutionService.sync('FINICITY') ) +syncInstitutionQueue.process( + 'sync-teller-institutions', + async () => await institutionService.sync('TELLER') +) + syncInstitutionQueue.add( 'sync-plaid-institutions', {}, @@ -131,6 +136,14 @@ syncInstitutionQueue.add( } ) +syncInstitutionQueue.add( + 'sync-teller-institutions', + {}, + { + repeat: { cron: '0 0 */1 * *' }, // Run every 24 hours + } +) + /** * send-email queue */ diff --git a/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx b/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx index 2b7c81f8..d3be9221 100644 --- a/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx +++ b/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx @@ -7,12 +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' const SEARCH_DEBOUNCE_MS = 300 @@ -23,6 +25,7 @@ export default function AccountTypeSelector({ view: string onViewChange: (view: string) => void }) { + const logger = useLogger() const { setAccountManager } = useAccountContext() const [searchQuery, setSearchQuery] = useState('') @@ -33,8 +36,11 @@ export default function AccountTypeSelector({ debouncedSearchQuery.length >= MIN_QUERY_LENGTH && view !== 'manual' + const config = useTellerConfig(logger) + const { openPlaid } = usePlaid() const { openFinicity } = useFinicity() + const { open: openTeller } = useTellerConnect(config, logger) const inputRef = useRef(null) @@ -77,6 +83,9 @@ export default function AccountTypeSelector({ case 'FINICITY': openFinicity(providerInstitution.providerId) break + case 'TELLER': + openTeller(providerInstitution.providerId) + break default: break } @@ -138,11 +147,12 @@ export default function AccountTypeSelector({ categoryUser: 'crypto', }, }) - return } - if (!data) return + if (!data) { + return + } switch (data.provider) { case 'PLAID': @@ -151,6 +161,9 @@ export default function AccountTypeSelector({ case 'FINICITY': openFinicity(data.providerId) break + case 'TELLER': + openTeller(data.providerId) + break default: break } diff --git a/libs/client/features/src/accounts-manager/InstitutionGrid.tsx b/libs/client/features/src/accounts-manager/InstitutionGrid.tsx index bdb55845..4321a6cf 100644 --- a/libs/client/features/src/accounts-manager/InstitutionGrid.tsx +++ b/libs/client/features/src/accounts-manager/InstitutionGrid.tsx @@ -14,48 +14,48 @@ const banks: GridImage[] = [ src: 'chase-bank.png', alt: 'Chase Bank', institution: { - provider: 'PLAID', - providerId: 'ins_56', + provider: 'TELLER', + providerId: 'chase', }, }, { src: 'capital-one.png', alt: 'Capital One Bank', institution: { - provider: 'PLAID', - providerId: 'ins_128026', + provider: 'TELLER', + providerId: 'capital_one', }, }, { src: 'wells-fargo.png', alt: 'Wells Fargo Bank', institution: { - provider: 'PLAID', - providerId: 'ins_127991', + provider: 'TELLER', + providerId: 'wells_fargo', }, }, { src: 'american-express.png', alt: 'American Express Bank', institution: { - provider: 'PLAID', - providerId: 'ins_10', + provider: 'TELLER', + providerId: 'amex', }, }, { src: 'bofa.png', alt: 'Bank of America', institution: { - provider: 'PLAID', - providerId: 'ins_127989', + provider: 'TELLER', + providerId: 'bank_of_america', }, }, { src: 'usaa-bank.png', alt: 'USAA Bank', institution: { - provider: 'PLAID', - providerId: 'ins_7', + provider: 'TELLER', + providerId: 'usaa', }, }, ] 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..d67f4e69 --- /dev/null +++ b/libs/client/shared/src/api/useTellerApi.ts @@ -0,0 +1,64 @@ +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 type { AxiosInstance } from 'axios' +import type { TellerTypes } from '@maybe-finance/teller-api' +import { useAccountConnectionApi } from './useAccountConnectionApi' + +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 { useSyncConnection } = useAccountConnectionApi() + const syncConnection = useSyncConnection() + + 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) + syncConnection.mutate(_connection.id) + toast.success(`Account connection added!`) + }, + }) + + 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..a77ac913 --- /dev/null +++ b/libs/client/shared/src/hooks/useTeller.ts @@ -0,0 +1,182 @@ +import { useEffect, useState } from 'react' +import * as Sentry from '@sentry/react' +import type { Logger } from '../providers/LogProvider' +import toast from 'react-hot-toast' +import { useAccountContext } from '../providers' +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 { setAccountManager } = useAccountContext() + 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') + 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() + setAccountManager({ view: 'idle' }) + }, + } +} + +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/providers/AccountContextProvider.tsx b/libs/client/shared/src/providers/AccountContextProvider.tsx index 85261e6f..85b60da3 100644 --- a/libs/client/shared/src/providers/AccountContextProvider.tsx +++ b/libs/client/shared/src/providers/AccountContextProvider.tsx @@ -49,6 +49,7 @@ type AccountManager = | { view: 'idle' } | { view: 'add-plaid'; linkToken: string } | { view: 'add-finicity' } + | { view: 'add-teller' } | { view: 'add-account' } | { view: 'add-property'; defaultValues: Partial } | { view: 'add-vehicle'; defaultValues: Partial } diff --git a/libs/server/features/src/institution/institution.service.ts b/libs/server/features/src/institution/institution.service.ts index 32cfeebd..ed76de28 100644 --- a/libs/server/features/src/institution/institution.service.ts +++ b/libs/server/features/src/institution/institution.service.ts @@ -271,7 +271,7 @@ export class InstitutionService implements IInstitutionService { provider_institution pi SET institution_id = i.id, - rank = (CASE WHEN pi.provider = 'PLAID' THEN 1 ELSE 0 END) + rank = (CASE WHEN pi.provider = 'TELLER' THEN 1 ELSE 0 END) FROM duplicates d INNER JOIN institutions i ON i.name = d.name AND i.url = d.url diff --git a/libs/server/features/src/providers/teller/teller.etl.ts b/libs/server/features/src/providers/teller/teller.etl.ts index 241934bb..b81e2a4b 100644 --- a/libs/server/features/src/providers/teller/teller.etl.ts +++ b/libs/server/features/src/providers/teller/teller.etl.ts @@ -1,6 +1,6 @@ import type { AccountConnection, PrismaClient } from '@prisma/client' import type { Logger } from 'winston' -import { SharedUtil, AccountUtil, type SharedType } from '@maybe-finance/shared' +import { AccountUtil, SharedUtil, type SharedType } from '@maybe-finance/shared' import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api' import { DbUtil, TellerUtil, type IETL, type ICryptoService } from '@maybe-finance/server/shared' import { Prisma } from '@prisma/client' @@ -101,16 +101,7 @@ export class TellerETL implements IETL { private async _extractAccounts(accessToken: string) { const accounts = await this.teller.getAccounts({ accessToken }) - const accountsWithBalances = await Promise.all( - accounts.map(async (a) => { - const balance = await this.teller.getAccountBalances({ - accountId: a.id, - accessToken, - }) - return { ...a, balance } - }) - ) - return accountsWithBalances + return accounts } private _loadAccounts(connection: Connection, { accounts }: Pick) { @@ -119,6 +110,7 @@ export class TellerETL implements IETL { ...accounts.map((tellerAccount) => { const type = TellerUtil.getType(tellerAccount.type) const classification = AccountUtil.getClassification(type) + return this.prisma.account.upsert({ where: { accountConnectionId_tellerAccountId: { @@ -132,6 +124,7 @@ export class TellerETL implements IETL { categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type), subcategoryProvider: tellerAccount.subtype ?? 'other', accountConnectionId: connection.id, + userId: connection.userId, tellerAccountId: tellerAccount.id, name: tellerAccount.name, tellerType: tellerAccount.type, @@ -210,7 +203,7 @@ export class TellerETL implements IETL { ${Prisma.join( chunk.map((tellerTransaction) => { const { - id, + id: transactionId, account_id, description, amount, @@ -224,15 +217,15 @@ export class TellerETL implements IETL { (SELECT id FROM account WHERE account_connection_id = ${ connection.id } AND teller_account_id = ${account_id.toString()}), - ${id}, + ${transactionId}, ${date}::date, - ${[description].filter(Boolean).join(' ')}, + ${description}, ${DbUtil.toDecimal(-amount)}, ${status === 'pending'}, ${'USD'}, ${details.counterparty.name ?? ''}, ${type}, - ${details.category ?? ''}, + ${details.category ?? ''} )` }) )} diff --git a/libs/server/features/src/providers/teller/teller.service.ts b/libs/server/features/src/providers/teller/teller.service.ts index e884639f..3059f42a 100644 --- a/libs/server/features/src/providers/teller/teller.service.ts +++ b/libs/server/features/src/providers/teller/teller.service.ts @@ -6,19 +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' - -export interface ITellerConnect { - generateConnectUrl(userId: User['id'], institutionId: string): Promise<{ link: string }> - - generateFixConnectUrl( - userId: User['id'], - accountConnectionId: AccountConnection['id'] - ): Promise<{ link: string }> -} +import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api' export class TellerService implements IAccountConnectionProvider, IInstitutionProvider { constructor( @@ -44,6 +36,7 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr where: { id: connection.id }, data: { status: 'OK', + syncStatus: 'IDLE', }, }) break @@ -67,19 +60,28 @@ 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`) } } async getInstitutions() { const tellerInstitutions = await SharedUtil.paginate({ - pageSize: 500, + pageSize: 10000, delay: process.env.NODE_ENV !== 'production' ? { @@ -87,20 +89,20 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr milliseconds: 7_000, // Sandbox rate limited at 10 calls / minute } : undefined, - fetchData: (offset, count) => + fetchData: () => SharedUtil.withRetry( () => this.teller.getInstitutions().then((data) => { this.logger.debug( - `paginated teller fetch inst=${data.institutions.length} (total=${data.institutions.length} offset=${offset} count=${count})` + `teller fetch inst=${data.length} (total=${data.length})` ) - return data.institutions + return data }), { maxRetries: 3, onError: (error, attempt) => { this.logger.error( - `Teller fetch institutions request failed attempt=${attempt} offset=${offset} count=${count}`, + `Teller fetch institutions request failed attempt=${attempt}`, { error: ErrorUtil.parseError(error) } ) @@ -115,12 +117,57 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr return { providerId: id, name, - url: undefined, - logo: `https://teller.io/images/banks/${id}.jpg}`, - primaryColor: undefined, - oauth: undefined, + url: null, + logo: null, + logoUrl: `https://teller.io/images/banks/${id}.jpg`, + primaryColor: null, + oauth: false, data: tellerInstitution, } }) } + + 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') + } + + await this.prisma.user.update({ + where: { id: userId }, + data: { + tellerUserId: enrollment.user.id, + }, + }) + + const accountConnection = await 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', + }, + }) + + return accountConnection + } } diff --git a/libs/server/shared/src/services/queue.service.ts b/libs/server/shared/src/services/queue.service.ts index 319fef92..c0c1d8c7 100644 --- a/libs/server/shared/src/services/queue.service.ts +++ b/libs/server/shared/src/services/queue.service.ts @@ -70,7 +70,7 @@ export type SyncSecurityQueue = IQueue export type SyncInstitutionQueue = IQueue< {}, - 'sync-finicity-institutions' | 'sync-plaid-institutions' + 'sync-finicity-institutions' | 'sync-plaid-institutions' | 'sync-teller-institutions' > export type SendEmailQueue = IQueue diff --git a/libs/server/shared/src/utils/teller-utils.ts b/libs/server/shared/src/utils/teller-utils.ts index a8e8e3ee..ec44700a 100644 --- a/libs/server/shared/src/utils/teller-utils.ts +++ b/libs/server/shared/src/utils/teller-utils.ts @@ -2,8 +2,8 @@ import { Prisma, AccountCategory, AccountType, - type AccountClassification, type Account, + type AccountClassification, } from '@prisma/client' import type { TellerTypes } from '@maybe-finance/teller-api' import { Duration } from 'luxon' @@ -14,7 +14,7 @@ import { Duration } from 'luxon' export const TELLER_WINDOW_MAX = Duration.fromObject({ years: 1 }) export function getAccountBalanceData( - { balances, currency }: Pick, + { balance, currency }: Pick, classification: AccountClassification ): Pick< Account, @@ -24,16 +24,14 @@ export function getAccountBalanceData( | 'availableBalanceStrategy' | 'currencyCode' > { - // Flip balance values to positive for liabilities const sign = classification === 'liability' ? -1 : 1 - return { currentBalanceProvider: new Prisma.Decimal( - balances.ledger ? sign * Number(balances.ledger) : 0 + balance.ledger ? sign * Number(balance.ledger) : 0 ), currentBalanceStrategy: 'current', availableBalanceProvider: new Prisma.Decimal( - balances.available ? sign * Number(balances.available) : 0 + balance.available ? sign * Number(balance.available) : 0 ), availableBalanceStrategy: 'available', currencyCode: currency, diff --git a/libs/teller-api/src/teller-api.ts b/libs/teller-api/src/teller-api.ts index ca3a180a..59d70483 100644 --- a/libs/teller-api/src/teller-api.ts +++ b/libs/teller-api/src/teller-api.ts @@ -34,7 +34,20 @@ export class TellerApi { */ async getAccounts({ accessToken }: AuthenticatedRequest): Promise { - return this.get(`/accounts`, accessToken) + const accounts = await this.get(`/accounts`, accessToken) + const accountsWithBalances = await Promise.all( + accounts.map(async (account) => { + const balance = await this.getAccountBalances({ + accountId: account.id, + accessToken, + }) + return { + ...account, + balance, + } + }) + ) + return accountsWithBalances } /** @@ -137,12 +150,12 @@ export class TellerApi { } private async getApi(accessToken: string): Promise { - const cert = fs.readFileSync('../../../certs/teller-certificate.pem', 'utf8') - const key = fs.readFileSync('../../../certs/teller-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,16 +166,16 @@ export class TellerApi { headers: { Accept: 'application/json', }, + auth: { + username: accessToken, + password: '', + }, }) - - this.api.interceptors.request.use((config) => { - // Add the access_token to the auth object - config.auth = { - username: 'ACCESS_TOKEN', - password: accessToken, - } - return config - }) + } else if (this.api.defaults.auth?.username !== accessToken) { + this.api.defaults.auth = { + username: accessToken, + password: '', + } } return this.api diff --git a/libs/teller-api/src/types/accounts.ts b/libs/teller-api/src/types/accounts.ts index 5df29953..34620987 100644 --- a/libs/teller-api/src/types/accounts.ts +++ b/libs/teller-api/src/types/accounts.ts @@ -12,13 +12,15 @@ export enum AccountType { export type DepositorySubtypes = | 'checking' | 'savings' - | 'money market' - | 'certificate of deposit' + | 'money_market' + | 'certificate_of_deposit' | 'treasury' | 'sweep' export type CreditSubtype = 'credit_card' +export type AccountStatus = 'open' | 'closed' + interface BaseAccount { enrollment_id: string links: { @@ -34,7 +36,7 @@ interface BaseAccount { currency: string id: string last_four: string - status: 'open' | 'closed' + status: AccountStatus } interface DepositoryAccount extends BaseAccount { @@ -50,10 +52,10 @@ interface CreditAccount extends BaseAccount { export type Account = DepositoryAccount | CreditAccount export type AccountWithBalances = Account & { - balances: AccountBalance + balance: AccountBalance } -export type GetAccountsResponse = Account[] +export type GetAccountsResponse = AccountWithBalances[] export type GetAccountResponse = Account export type DeleteAccountResponse = void 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/libs/teller-api/src/types/institutions.ts b/libs/teller-api/src/types/institutions.ts index 6f243375..3a593e14 100644 --- a/libs/teller-api/src/types/institutions.ts +++ b/libs/teller-api/src/types/institutions.ts @@ -9,6 +9,4 @@ export type Institution = { type Capability = 'detail' | 'balance' | 'transaction' | 'identity' -export type GetInstitutionsResponse = { - institutions: Institution[] -} +export type GetInstitutionsResponse = Institution[] diff --git a/package.json b/package.json index 96c52336..c1328b00 100644 --- a/package.json +++ b/package.json @@ -147,12 +147,14 @@ "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", "stripe": "^10.17.0", "superjson": "^1.11.0", "tailwindcss": "3.2.4", + "teller-connect-react": "^0.1.0", "tslib": "^2.3.0", "uuid": "^9.0.0", "winston": "^3.8.2", @@ -163,6 +165,7 @@ "@babel/core": "7.17.5", "@babel/preset-react": "^7.14.5", "@babel/preset-typescript": "7.16.7", + "@faker-js/faker": "^8.3.1", "@fast-csv/parse": "^4.3.6", "@next/bundle-analyzer": "^13.1.1", "@nrwl/cli": "15.5.2", diff --git a/prisma/migrations/20240116185600_add_teller_provider/migration.sql b/prisma/migrations/20240116185600_add_teller_provider/migration.sql new file mode 100644 index 00000000..20c526f2 --- /dev/null +++ b/prisma/migrations/20240116185600_add_teller_provider/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "Provider" ADD VALUE 'TELLER'; 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/migrations/20240117191553_categories_for_teller/migration.sql b/prisma/migrations/20240117191553_categories_for_teller/migration.sql new file mode 100644 index 00000000..7cc8647f --- /dev/null +++ b/prisma/migrations/20240117191553_categories_for_teller/migration.sql @@ -0,0 +1,223 @@ +-- AlterTable +BEGIN; + +ALTER TABLE + "transaction" RENAME COLUMN "category" TO "category_old"; + +ALTER TABLE + "transaction" RENAME COLUMN "category_user" TO "category_user_old"; + +DROP VIEW IF EXISTS transactions_enriched; + +ALTER TABLE + "transaction" +ADD + COLUMN "category_user" TEXT; + +ALTER TABLE + "transaction" +ADD + COLUMN "category" TEXT NOT NULL GENERATED ALWAYS AS( + COALESCE( + category_user, + CASE + WHEN ( + ( + plaid_personal_finance_category ->> 'primary' :: text + ) = 'INCOME' :: text + ) THEN 'Income' :: text + WHEN ( + ( + plaid_personal_finance_category ->> 'detailed' :: text + ) = ANY ( + ARRAY ['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text] + ) + ) THEN 'Housing Payments' :: text + WHEN ( + ( + plaid_personal_finance_category ->> 'detailed' :: text + ) = 'LOAN_PAYMENTS_CAR_PAYMENT' :: text + ) THEN 'Vehicle Payments' :: text + WHEN ( + ( + plaid_personal_finance_category ->> 'primary' :: text + ) = 'LOAN_PAYMENTS' :: text + ) THEN 'Other Payments' :: text + WHEN ( + ( + plaid_personal_finance_category ->> 'primary' :: text + ) = 'HOME_IMPROVEMENT' :: text + ) THEN 'Home Improvement' :: text + WHEN ( + ( + plaid_personal_finance_category ->> 'primary' :: text + ) = 'GENERAL_MERCHANDISE' :: text + ) THEN 'Shopping' :: text + WHEN ( + ( + ( + plaid_personal_finance_category ->> 'primary' :: text + ) = 'RENT_AND_UTILITIES' :: text + ) + AND ( + ( + plaid_personal_finance_category ->> 'detailed' :: text + ) <> 'RENT_AND_UTILITIES_RENT' :: text + ) + ) THEN 'Utilities' :: text + WHEN ( + ( + plaid_personal_finance_category ->> 'primary' :: text + ) = 'FOOD_AND_DRINK' :: text + ) THEN 'Food and Drink' :: text + WHEN ( + ( + plaid_personal_finance_category ->> 'primary' :: text + ) = 'TRANSPORTATION' :: text + ) THEN 'Transportation' :: text + WHEN ( + ( + plaid_personal_finance_category ->> 'primary' :: text + ) = 'TRAVEL' :: text + ) THEN 'Travel' :: text + WHEN ( + ( + ( + plaid_personal_finance_category ->> 'primary' :: text + ) = ANY (ARRAY ['PERSONAL_CARE'::text, 'MEDICAL'::text]) + ) + AND ( + ( + plaid_personal_finance_category ->> 'detailed' :: text + ) <> 'MEDICAL_VETERINARY_SERVICES' :: text + ) + ) THEN 'Health' :: text + WHEN ( + (finicity_categorization ->> 'category' :: text) = ANY (ARRAY ['Income'::text, 'Paycheck'::text]) + ) THEN 'Income' :: text + WHEN ( + (finicity_categorization ->> 'category' :: text) = 'Mortgage & Rent' :: text + ) THEN 'Housing Payments' :: text + WHEN ( + (finicity_categorization ->> 'category' :: text) = ANY ( + ARRAY ['Furnishings'::text, 'Home Services'::text, 'Home Improvement'::text, 'Lawn and Garden'::text] + ) + ) THEN 'Home Improvement' :: text + WHEN ( + (finicity_categorization ->> 'category' :: text) = ANY ( + ARRAY ['Streaming Services'::text, 'Home Phone'::text, 'Television'::text, 'Bills & Utilities'::text, 'Utilities'::text, 'Internet / Broadband Charges'::text, 'Mobile Phone'::text] + ) + ) THEN 'Utilities' :: text + WHEN ( + (finicity_categorization ->> 'category' :: text) = ANY ( + ARRAY ['Fast Food'::text, 'Food & Dining'::text, 'Restaurants'::text, 'Coffee Shops'::text, 'Alcohol & Bars'::text, 'Groceries'::text] + ) + ) THEN 'Food and Drink' :: text + WHEN ( + (finicity_categorization ->> 'category' :: text) = ANY ( + ARRAY ['Auto & Transport'::text, 'Gas & Fuel'::text, 'Auto Insurance'::text] + ) + ) THEN 'Transportation' :: text + WHEN ( + (finicity_categorization ->> 'category' :: text) = ANY ( + ARRAY ['Hotel'::text, 'Travel'::text, 'Rental Car & Taxi'::text] + ) + ) THEN 'Travel' :: text + WHEN ( + (finicity_categorization ->> 'category' :: text) = ANY ( + ARRAY ['Health Insurance'::text, 'Doctor'::text, 'Pharmacy'::text, 'Eyecare'::text, 'Health & Fitness'::text, 'Personal Care'::text] + ) + ) THEN 'Health' :: text + WHEN (teller_category = 'income' :: text) THEN 'Income' :: text + WHEN (teller_category = 'home' :: text) THEN 'Home Improvement' :: text + WHEN ( + teller_category = ANY (ARRAY ['phone'::text, 'utilities'::text]) + ) THEN 'Utilities' :: text + WHEN ( + teller_category = ANY ( + ARRAY ['dining'::text, 'bar'::text, 'groceries'::text] + ) + ) THEN 'Food and Drink' :: text + WHEN ( + teller_category = ANY ( + ARRAY ['clothing'::text, 'entertainment'::text, 'shopping'::text, 'electronics'::text, 'software'::text, 'sport'::text] + ) + ) THEN 'Shopping' :: text + WHEN ( + teller_category = ANY (ARRAY ['transportation'::text, 'fuel'::text]) + ) THEN 'Transportation' :: text + WHEN ( + teller_category = ANY (ARRAY ['accommodation'::text, 'transport'::text]) + ) THEN 'Travel' :: text + WHEN (teller_category = 'health' :: text) THEN 'Health' :: text + WHEN ( + teller_category = ANY ( + ARRAY ['loan'::text, 'tax'::text, 'insurance'::text, 'office'::text] + ) + ) THEN 'Other Payments' :: text + ELSE 'Other' :: text + END + ) + ) STORED; + +CREATE +OR REPLACE VIEW transactions_enriched AS ( + SELECT + t.id, + t.created_at as "createdAt", + t.updated_at as "updatedAt", + t.name, + t.account_id as "accountId", + t.date, + t.flow, + COALESCE( + t.type_user, + CASE + -- no matching transaction + WHEN t.match_id IS NULL THEN ( + CASE + t.flow + WHEN 'INFLOW' THEN ( + CASE + a.classification + WHEN 'asset' THEN 'INCOME' :: "TransactionType" + WHEN 'liability' THEN 'PAYMENT' :: "TransactionType" + END + ) + WHEN 'OUTFLOW' THEN 'EXPENSE' :: "TransactionType" + END + ) -- has matching transaction + ELSE ( + CASE + a.classification + WHEN 'asset' THEN 'TRANSFER' :: "TransactionType" + WHEN 'liability' THEN 'PAYMENT' :: "TransactionType" + END + ) + END + ) AS "type", + t.type_user as "typeUser", + t.amount, + t.currency_code as "currencyCode", + t.pending, + t.merchant_name as "merchantName", + t.category, + t.category_user as "categoryUser", + t.excluded, + t.match_id as "matchId", + COALESCE(ac.user_id, a.user_id) as "userId", + a.classification as "accountClassification", + a.type as "accountType" + FROM + transaction t + inner join account a on a.id = t.account_id + left join account_connection ac on a.account_connection_id = ac.id +); + +ALTER TABLE + "transaction" DROP COLUMN "category_old"; + +ALTER TABLE + "transaction" DROP COLUMN "category_user_old"; + +COMMIT; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 007cad61..d358089e 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") @@ -340,7 +340,7 @@ model Transaction { currencyCode String @default("USD") @map("currency_code") pending Boolean @default(false) merchantName String? @map("merchant_name") - category String @default(dbgenerated("COALESCE(category_user,\nCASE\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'INCOME'::text) THEN 'Income'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = ANY (ARRAY['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text])) THEN 'Housing Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = 'LOAN_PAYMENTS_CAR_PAYMENT'::text) THEN 'Vehicle Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'LOAN_PAYMENTS'::text) THEN 'Other Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'HOME_IMPROVEMENT'::text) THEN 'Home Improvement'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'GENERAL_MERCHANDISE'::text) THEN 'Shopping'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = 'RENT_AND_UTILITIES'::text) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'RENT_AND_UTILITIES_RENT'::text)) THEN 'Utilities'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'FOOD_AND_DRINK'::text) THEN 'Food and Drink'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRANSPORTATION'::text) THEN 'Transportation'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRAVEL'::text) THEN 'Travel'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = ANY (ARRAY['PERSONAL_CARE'::text, 'MEDICAL'::text])) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'MEDICAL_VETERINARY_SERVICES'::text)) THEN 'Health'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Income'::text, 'Paycheck'::text])) THEN 'Income'::text\n WHEN ((finicity_categorization ->> 'category'::text) = 'Mortgage & Rent'::text) THEN 'Housing Payments'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Furnishings'::text, 'Home Services'::text, 'Home Improvement'::text, 'Lawn and Garden'::text])) THEN 'Home Improvement'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Streaming Services'::text, 'Home Phone'::text, 'Television'::text, 'Bills & Utilities'::text, 'Utilities'::text, 'Internet / Broadband Charges'::text, 'Mobile Phone'::text])) THEN 'Utilities'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Fast Food'::text, 'Food & Dining'::text, 'Restaurants'::text, 'Coffee Shops'::text, 'Alcohol & Bars'::text, 'Groceries'::text])) THEN 'Food and Drink'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Auto & Transport'::text, 'Gas & Fuel'::text, 'Auto Insurance'::text])) THEN 'Transportation'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Hotel'::text, 'Travel'::text, 'Rental Car & Taxi'::text])) THEN 'Travel'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Health Insurance'::text, 'Doctor'::text, 'Pharmacy'::text, 'Eyecare'::text, 'Health & Fitness'::text, 'Personal Care'::text])) THEN 'Health'::text\n ELSE 'Other'::text\nEND)")) + category String @default(dbgenerated("COALESCE(category_user,\nCASE\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'INCOME'::text) THEN 'Income'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = ANY (ARRAY['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text])) THEN 'Housing Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = 'LOAN_PAYMENTS_CAR_PAYMENT'::text) THEN 'Vehicle Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'LOAN_PAYMENTS'::text) THEN 'Other Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'HOME_IMPROVEMENT'::text) THEN 'Home Improvement'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'GENERAL_MERCHANDISE'::text) THEN 'Shopping'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = 'RENT_AND_UTILITIES'::text) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'RENT_AND_UTILITIES_RENT'::text)) THEN 'Utilities'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'FOOD_AND_DRINK'::text) THEN 'Food and Drink'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRANSPORTATION'::text) THEN 'Transportation'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRAVEL'::text) THEN 'Travel'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = ANY (ARRAY['PERSONAL_CARE'::text, 'MEDICAL'::text])) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'MEDICAL_VETERINARY_SERVICES'::text)) THEN 'Health'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Income'::text, 'Paycheck'::text])) THEN 'Income'::text\n WHEN ((finicity_categorization ->> 'category'::text) = 'Mortgage & Rent'::text) THEN 'Housing Payments'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Furnishings'::text, 'Home Services'::text, 'Home Improvement'::text, 'Lawn and Garden'::text])) THEN 'Home Improvement'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Streaming Services'::text, 'Home Phone'::text, 'Television'::text, 'Bills & Utilities'::text, 'Utilities'::text, 'Internet / Broadband Charges'::text, 'Mobile Phone'::text])) THEN 'Utilities'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Fast Food'::text, 'Food & Dining'::text, 'Restaurants'::text, 'Coffee Shops'::text, 'Alcohol & Bars'::text, 'Groceries'::text])) THEN 'Food and Drink'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Auto & Transport'::text, 'Gas & Fuel'::text, 'Auto Insurance'::text])) THEN 'Transportation'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Hotel'::text, 'Travel'::text, 'Rental Car & Taxi'::text])) THEN 'Travel'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Health Insurance'::text, 'Doctor'::text, 'Pharmacy'::text, 'Eyecare'::text, 'Health & Fitness'::text, 'Personal Care'::text])) THEN 'Health'::text\n WHEN (teller_category = 'income'::text) THEN 'Income'::text\n WHEN (teller_category = 'home'::text) THEN 'Home Improvement'::text\n WHEN (teller_category = ANY (ARRAY['phone'::text, 'utilities'::text])) THEN 'Utilities'::text\n WHEN (teller_category = ANY (ARRAY['dining'::text, 'bar'::text, 'groceries'::text])) THEN 'Food and Drink'::text\n WHEN (teller_category = ANY (ARRAY['clothing'::text, 'entertainment'::text, 'shopping'::text, 'electronics'::text, 'software'::text, 'sport'::text])) THEN 'Shopping'::text\n WHEN (teller_category = ANY (ARRAY['transportation'::text, 'fuel'::text])) THEN 'Transportation'::text\n WHEN (teller_category = ANY (ARRAY['accommodation'::text, 'transport'::text])) THEN 'Travel'::text\n WHEN (teller_category = 'health'::text) THEN 'Health'::text\n WHEN (teller_category = ANY (ARRAY['loan'::text, 'tax'::text, 'insurance'::text, 'office'::text])) THEN 'Other Payments'::text\n ELSE 'Other'::text\nEND)")) categoryUser String? @map("category_user") excluded Boolean @default(false) @@ -493,6 +493,7 @@ model Institution { enum Provider { PLAID FINICITY + TELLER } model ProviderInstitution { diff --git a/tools/generators/index.ts b/tools/generators/index.ts new file mode 100644 index 00000000..42ab4166 --- /dev/null +++ b/tools/generators/index.ts @@ -0,0 +1 @@ +export * as TellerGenerator from './tellerGenerator' diff --git a/tools/generators/tellerGenerator.ts b/tools/generators/tellerGenerator.ts new file mode 100644 index 00000000..8ee131b4 --- /dev/null +++ b/tools/generators/tellerGenerator.ts @@ -0,0 +1,248 @@ +import { faker } from '@faker-js/faker' +import type { TellerTypes } from '../../libs/teller-api/src' + +function generateSubType( + type: TellerTypes.AccountTypes +): TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype { + if (type === 'depository') { + return faker.helpers.arrayElement([ + 'checking', + 'savings', + 'money_market', + 'certificate_of_deposit', + 'treasury', + 'sweep', + ]) as TellerTypes.DepositorySubtypes + } else { + return 'credit_card' as TellerTypes.CreditSubtype + } +} + +type GenerateAccountsParams = { + count: number + enrollmentId: string + institutionName: string + institutionId: string +} + +export function generateAccounts({ + count, + enrollmentId, + institutionName, + institutionId, +}: GenerateAccountsParams) { + const accounts: TellerTypes.Account[] = [] + for (let i = 0; i < count; i++) { + const accountId = faker.string.uuid() + const lastFour = faker.finance.creditCardNumber().slice(-4) + const type: TellerTypes.AccountTypes = faker.helpers.arrayElement(['depository', 'credit']) + let subType: TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype + subType = generateSubType(type) + + const accountStub = { + enrollment_id: enrollmentId, + links: { + balances: `https://api.teller.io/accounts/${accountId}/balances`, + self: `https://api.teller.io/accounts/${accountId}`, + transactions: `https://api.teller.io/accounts/${accountId}/transactions`, + }, + institution: { + name: institutionName, + id: institutionId, + }, + name: faker.finance.accountName(), + currency: 'USD', + id: accountId, + last_four: lastFour, + status: faker.helpers.arrayElement(['open', 'closed']) as TellerTypes.AccountStatus, + } + + if (faker.datatype.boolean()) { + accounts.push({ + ...accountStub, + type: 'depository', + subtype: faker.helpers.arrayElement([ + 'checking', + 'savings', + 'money_market', + 'certificate_of_deposit', + 'treasury', + 'sweep', + ]), + }) + } else { + accounts.push({ + ...accountStub, + type: 'credit', + subtype: 'credit_card', + }) + } + } + return accounts +} + +export function generateBalance(account_id: string): TellerTypes.AccountBalance { + const amount = faker.finance.amount() + return { + available: amount, + ledger: amount, + links: { + account: `https://api.teller.io/accounts/${account_id}`, + self: `https://api.teller.io/accounts/${account_id}/balances`, + }, + account_id, + } +} + +type GenerateAccountsWithBalancesParams = { + count: number + enrollmentId: string + institutionName: string + institutionId: string +} + +export function generateAccountsWithBalances({ + count, + enrollmentId, + institutionName, + institutionId, +}: GenerateAccountsWithBalancesParams): TellerTypes.AccountWithBalances[] { + const accountsWithBalances: TellerTypes.AccountWithBalances[] = [] + for (let i = 0; i < count; i++) { + const account = generateAccounts({ + count, + enrollmentId, + institutionName, + institutionId, + })[0] + const balance = generateBalance(account.id) + accountsWithBalances.push({ + ...account, + balance, + }) + } + return accountsWithBalances +} + +export function generateTransactions(count: number, accountId: string): TellerTypes.Transaction[] { + const transactions: TellerTypes.Transaction[] = [] + + for (let i = 0; i < count; i++) { + const transactionId = `txn_${faker.string.uuid()}` + const transaction = { + details: { + processing_status: faker.helpers.arrayElement(['complete', 'pending']), + category: faker.helpers.arrayElement([ + 'accommodation', + 'advertising', + 'bar', + 'charity', + 'clothing', + 'dining', + 'education', + 'electronics', + 'entertainment', + 'fuel', + 'general', + 'groceries', + 'health', + 'home', + 'income', + 'insurance', + 'investment', + 'loan', + 'office', + 'phone', + 'service', + 'shopping', + 'software', + 'sport', + 'tax', + 'transport', + 'transportation', + 'utilities', + ]), + counterparty: { + name: faker.company.name(), + type: faker.helpers.arrayElement(['person', 'business']), + }, + }, + running_balance: null, + description: faker.word.words({ count: { min: 3, max: 10 } }), + id: transactionId, + date: faker.date.recent({ days: 30 }).toISOString().split('T')[0], // recent date in 'YYYY-MM-DD' format + account_id: accountId, + links: { + account: `https://api.teller.io/accounts/${accountId}`, + self: `https://api.teller.io/accounts/${accountId}/transactions/${transactionId}`, + }, + amount: faker.finance.amount(), + type: faker.helpers.arrayElement(['transfer', 'deposit', 'withdrawal']), + status: faker.helpers.arrayElement(['pending', 'posted']), + } as TellerTypes.Transaction + transactions.push(transaction) + } + return transactions +} + +export function generateEnrollment(): TellerTypes.Enrollment & { institutionId: string } { + const institutionName = faker.company.name() + const institutionId = institutionName.toLowerCase().replace(/\s/g, '_') + return { + accessToken: `token_${faker.string.alphanumeric(15)}`, + user: { + id: `usr_${faker.string.alphanumeric(15)}`, + }, + enrollment: { + id: `enr_${faker.string.alphanumeric(15)}`, + institution: { + name: institutionName, + }, + }, + signatures: [faker.string.alphanumeric(15)], + institutionId, + } +} + +type GenerateConnectionsResponse = { + enrollment: TellerTypes.Enrollment & { institutionId: string } + accounts: TellerTypes.Account[] + accountsWithBalances: TellerTypes.AccountWithBalances[] + transactions: TellerTypes.Transaction[] +} + +export function generateConnection(): GenerateConnectionsResponse { + const accountsWithBalances: TellerTypes.AccountWithBalances[] = [] + const accounts: TellerTypes.Account[] = [] + const transactions: TellerTypes.Transaction[] = [] + + const enrollment = generateEnrollment() + + const accountCount: number = faker.number.int({ min: 1, max: 3 }) + + const enrollmentId = enrollment.enrollment.id + const institutionName = enrollment.enrollment.institution.name + const institutionId = enrollment.institutionId + accountsWithBalances.push( + ...generateAccountsWithBalances({ + count: accountCount, + enrollmentId, + institutionName, + institutionId, + }) + ) + for (const account of accountsWithBalances) { + const { balance, ...accountWithoutBalance } = account + accounts.push(accountWithoutBalance) + const transactionsCount: number = faker.number.int({ min: 1, max: 5 }) + const generatedTransactions = generateTransactions(transactionsCount, account.id) + transactions.push(...generatedTransactions) + } + + return { + enrollment, + accounts, + accountsWithBalances, + transactions, + } +} diff --git a/workspace.json b/workspace.json index 012b52a1..9e263386 100644 --- a/workspace.json +++ b/workspace.json @@ -405,7 +405,7 @@ }, "test": { "executor": "@nrwl/jest:jest", - "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "outputs": ["/coverage/libs/teller-api"], "options": { "jestConfig": "libs/teller-api/jest.config.ts", "passWithNoTests": true diff --git a/yarn.lock b/yarn.lock index e301ab27..8121c329 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1730,6 +1730,11 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@faker-js/faker@^8.3.1": + version "8.3.1" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-8.3.1.tgz#7753df0cb88d7649becf984a96dd1bd0a26f43e3" + integrity sha512-FdgpFxY6V6rLZE9mmIBb9hM0xpfvQOSNOLnzolzKwsE1DH+gC7lEKV1p1IbR0lAYyvYd5a4u3qWJzowUkw1bIw== + "@fast-csv/format@^4.3.5": version "4.3.5" resolved "https://registry.yarnpkg.com/@fast-csv/format/-/format-4.3.5.tgz#90d83d1b47b6aaf67be70d6118f84f3e12ee1ff3" @@ -16600,6 +16605,11 @@ react-script-hook@^1.6.0: resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.6.0.tgz#6a44ff5e65113cb29252eadad1b8306f5fe0c626" integrity sha512-aJm72XGWV+wJTKiqHmAaTNC/JQZV/Drv6A1kd1VQlzhzAXLqtBRBeTt3iTESImGe5TaBDHUOUeaGNw4v+7bqDw== +react-script-hook@^1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.7.2.tgz#ec130d67f9a25fcde57fbfd1faa87e5b97521948" + integrity sha512-fhyCEfXb94fag34UPRF0zry1XGwmVY+79iibWwTqAoOiCzYJQOYTiWJ7CnqglA9tMSV8g45cQpHCMcBwr7dwhA== + react-shallow-renderer@^16.15.0: version "16.15.0" resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz#48fb2cf9b23d23cde96708fe5273a7d3446f4457" @@ -18532,6 +18542,13 @@ telejson@^6.0.8: lodash "^4.17.21" memoizerific "^1.11.3" +teller-connect-react@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/teller-connect-react/-/teller-connect-react-0.1.0.tgz#b3bae24f4410d622eb8c88c7668adb003eb7bfd7" + integrity sha512-ZI+OULCsuo/v1qetpjepOgM7TyIzwnMVE/54IruOPguQtJ/Ui3C1ax3wUb65AKZDyVQ7ZyjA+8ypT/yMYD9bIQ== + dependencies: + react-script-hook "^1.7.2" + terminal-link@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994"