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/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 00000000..48d5f81f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,10 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: '' +assignees: '' + +--- + + diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..b0d2eed5 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,84 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '20 21 * * 1' + +jobs: + analyze: + name: Analyze + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners + # Consider using larger runners for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + # required for all workflows + security-events: write + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + language: [ 'javascript-typescript' ] + # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] + # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/snyk-security.yml b/.github/workflows/snyk-security.yml new file mode 100644 index 00000000..e1fbaa66 --- /dev/null +++ b/.github/workflows/snyk-security.yml @@ -0,0 +1,79 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# A sample workflow which sets up Snyk to analyze the full Snyk platform (Snyk Open Source, Snyk Code, +# Snyk Container and Snyk Infrastructure as Code) +# The setup installs the Snyk CLI - for more details on the possible commands +# check https://docs.snyk.io/snyk-cli/cli-reference +# The results of Snyk Code are then uploaded to GitHub Security Code Scanning +# +# In order to use the Snyk Action you will need to have a Snyk API token. +# More details in https://github.com/snyk/actions#getting-your-snyk-token +# or you can signup for free at https://snyk.io/login +# +# For more examples, including how to limit scans to only high-severity issues +# and fail PR checks, see https://github.com/snyk/actions/ + +name: Snyk Security + +on: + push: + branches: ["main" ] + pull_request: + branches: ["main"] + +permissions: + contents: read + +jobs: + snyk: + permissions: + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Snyk CLI to check for security issues + # Snyk can be used to break the build when it detects security issues. + # In this case we want to upload the SAST issues to GitHub Code Scanning + uses: snyk/actions/setup@806182742461562b67788a64410098c9d9b96adb + + # For Snyk Open Source you must first set up the development environment for your application's dependencies + # For example for Node + #- uses: actions/setup-node@v3 + # with: + # node-version: 16 + + env: + # This is where you will need to introduce the Snyk API token created with your Snyk account + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + + # Runs Snyk Code (SAST) analysis and uploads result into GitHub. + # Use || true to not fail the pipeline + - name: Snyk Code test + run: snyk code test --sarif > snyk-code.sarif # || true + + # Runs Snyk Open Source (SCA) analysis and uploads result to Snyk. + - name: Snyk Open Source monitor + run: snyk monitor --all-projects + + # Runs Snyk Infrastructure as Code (IaC) analysis and uploads result to Snyk. + # Use || true to not fail the pipeline. + - name: Snyk IaC test and report + run: snyk iac test --report # || true + + # Build the docker image for testing + - name: Build a Docker image + run: docker build -t your/image-to-test . + # Runs Snyk Container (Container and SCA) analysis and uploads result to Snyk. + - name: Snyk Container monitor + run: snyk container monitor your/image-to-test --file=Dockerfile + + # Push the Snyk Code results into GitHub Code Scanning tab + - name: Upload result to GitHub Code Scanning + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: snyk-code.sarif diff --git a/.vscode/settings.json b/.vscode/settings.json index b76293a8..475b191d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,7 @@ }, "[html]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "typescript.enablePromptUseWorkspaceTsdk": true, + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/README.md b/README.md index a3c8eca8..260a3a6d 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,6 @@ Get involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybe.co) • [Issues](https://github.com/maybe-finance/maybe/issues) -🚨 NOTE: This is the original React app of the previously-defunct personal finance app, Maybe. This original version used many external services (Plaid, Finicity, etc) and getting it to fully function will be a decent amount of work. - -There's a LOT of work to do to get this functioning, but it should be feasible. - ## Backstory We spent the better part of 2021/2022 building a personal finance + wealth management app called Maybe. Very full-featured, including an "Ask an Advisor" feature which connected users with an actual CFP/CFA to help them with their finances (all included in your subscription). @@ -37,9 +33,9 @@ As a personal finance + wealth management app, Maybe has a lot of features. Here And dozens upon dozens of smaller features. -## Building the app +## Getting started -This is the current state of building the app. You'll hit errors, which we're working to resolve (and certainly welcome PRs to help with that). +This is the current state of building the app. We're actively working to make this process much more streamlined! You'll need Docker installed to run the app locally. @@ -53,6 +49,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/client/pages/api/auth/[...nextauth].ts b/apps/client/pages/api/auth/[...nextauth].ts index 3a18412a..a641f0ac 100644 --- a/apps/client/pages/api/auth/[...nextauth].ts +++ b/apps/client/pages/api/auth/[...nextauth].ts @@ -85,7 +85,6 @@ export const authOptions = { strategy: 'jwt' as SessionStrategy, maxAge: 1 * 24 * 60 * 60, // 1 Day }, - providers: [ CredentialsProvider({ name: 'Credentials', diff --git a/apps/server/src/app/__tests__/account.integration.spec.ts b/apps/server/src/app/__tests__/account.integration.spec.ts index 23b068c7..d8ebb18e 100644 --- a/apps/server/src/app/__tests__/account.integration.spec.ts +++ b/apps/server/src/app/__tests__/account.integration.spec.ts @@ -9,7 +9,6 @@ import { } from '@maybe-finance/server/features' import { InMemoryQueueFactory, PgService, type IQueueFactory } from '@maybe-finance/server/shared' import { createLogger, transports } from 'winston' -import isCI from 'is-ci' import nock from 'nock' import Decimal from 'decimal.js' import { startServer, stopServer } from './utils/server' @@ -27,7 +26,7 @@ const prisma = new PrismaClient() // For TypeScript support const plaid = jest.mocked(_plaid) // eslint-disable-line -const auth0Id = isCI ? 'auth0|61afd38f678a0c006895f046' : 'auth0|61afd340678a0c006895f000' +const authId = '__TEST_USER_ID__' let axios: AxiosInstance let user: User @@ -38,7 +37,7 @@ if (process.env.IS_VSCODE_DEBUG === 'true') { beforeEach(async () => { // Clears old user and data, creates new user - user = await resetUser(auth0Id) + user = await resetUser(authId) }) describe('/v1/accounts API', () => { diff --git a/apps/server/src/app/__tests__/connection.integration.spec.ts b/apps/server/src/app/__tests__/connection.integration.spec.ts index 747f9ee0..7eb81099 100644 --- a/apps/server/src/app/__tests__/connection.integration.spec.ts +++ b/apps/server/src/app/__tests__/connection.integration.spec.ts @@ -2,7 +2,6 @@ import type { AxiosInstance } from 'axios' import type { SharedType } from '@maybe-finance/shared' import type { Prisma, AccountConnection, AccountSyncStatus, User } from '@prisma/client' import type { ItemRemoveResponse } from 'plaid' -import isCI from 'is-ci' import { startServer, stopServer } from './utils/server' import { getAxiosClient } from './utils/axios' import prisma from '../lib/prisma' @@ -18,7 +17,7 @@ jest.mock('plaid') // For TypeScript support const plaid = jest.mocked(_plaid) -const auth0Id = isCI ? 'auth0|61afd38f678a0c006895f046' : 'auth0|61afd340678a0c006895f000' +const authId = '__TEST_USER_ID__' let axios: AxiosInstance let user: User | null let connection: AccountConnection @@ -45,7 +44,7 @@ afterAll(async () => { }) beforeEach(async () => { - user = await resetUser(auth0Id) + user = await resetUser(authId) connectionData = { data: { diff --git a/apps/server/src/app/__tests__/utils/axios.ts b/apps/server/src/app/__tests__/utils/axios.ts index e5b80ac5..1449e1a2 100644 --- a/apps/server/src/app/__tests__/utils/axios.ts +++ b/apps/server/src/app/__tests__/utils/axios.ts @@ -1,38 +1,37 @@ import type { AxiosResponse } from 'axios' import type { SharedType } from '@maybe-finance/shared' import { superjson } from '@maybe-finance/shared' -import env from '../../../env' -import isCI from 'is-ci' import Axios from 'axios' +import { encode } from 'next-auth/jwt' -// Fetches Auth0 access token (JWT) and prepares Axios client to use it on each request export async function getAxiosClient() { - const tenantUrl = isCI - ? 'REPLACE_THIS-staging.us.auth0.com' - : 'REPLACE_THIS-development.us.auth0.com' - - const { - data: { access_token: token }, - } = await Axios.request({ - method: 'POST', - url: `https://${tenantUrl}/oauth/token`, - headers: { 'content-type': 'application/json' }, - data: { - grant_type: 'password', - username: 'REPLACE_THIS', - password: 'REPLACE_THIS', - audience: 'https://maybe-finance-api/v1', - scope: '', - client_id: isCI ? 'REPLACE_THIS' : 'REPLACE_THIS', + const baseUrl = 'http://127.0.0.1:53333/v1' + const jwt = await encode({ + maxAge: 1 * 24 * 60 * 60, + secret: process.env.NEXTAUTH_SECRET || 'CHANGE_ME', + token: { + sub: '__TEST_USER_ID__', + user: '__TEST_USER_ID__', + 'https://maybe.co/email': 'REPLACE_THIS', + firstName: 'REPLACE_THIS', + lastName: 'REPLACE_THIS', + name: 'REPLACE_THIS', }, }) + const defaultHeaders = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Credentials': true, + Authorization: `Bearer ${jwt}`, + } + const axiosOptions = { + baseURL: baseUrl, + headers: defaultHeaders, + } + const axios = Axios.create({ - baseURL: 'http://127.0.0.1:53333/v1', + ...axiosOptions, validateStatus: () => true, // Tests should determine whether status is correct, not Axios - headers: { - Authorization: `Bearer ${token}`, - }, }) axios.interceptors.response.use((response: AxiosResponse) => { 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/middleware/validate-auth-jwt.ts b/apps/server/src/app/middleware/validate-auth-jwt.ts index 5e46cec8..2124c88b 100644 --- a/apps/server/src/app/middleware/validate-auth-jwt.ts +++ b/apps/server/src/app/middleware/validate-auth-jwt.ts @@ -2,17 +2,20 @@ import cookieParser from 'cookie-parser' import { decode } from 'next-auth/jwt' const SECRET = process.env.NEXTAUTH_SECRET ?? 'REPLACE_THIS' - export const validateAuthJwt = async (req, res, next) => { cookieParser(SECRET)(req, res, async (err) => { if (err) { return res.status(500).json({ message: 'Internal Server Error' }) } - if (req.cookies && 'next-auth.session-token' in req.cookies) { + const cookieName = req.secure + ? '__Secure-next-auth.session-token' + : 'next-auth.session-token' + + if (req.cookies && cookieName in req.cookies) { try { const token = await decode({ - token: req.cookies['next-auth.session-token'], + token: req.cookies[cookieName], secret: SECRET, }) @@ -26,6 +29,18 @@ export const validateAuthJwt = async (req, res, next) => { console.error('Error in token validation', error) return res.status(500).json({ message: 'Internal Server Error' }) } + } else if (req.headers.authorization) { + const token = req.headers.authorization.split(' ')[1] + const decoded = await decode({ + token, + secret: SECRET, + }) + if (decoded) { + req.user = decoded + return next() + } else { + return res.status(401).json({ message: 'Unauthorized' }) + } } else { return res.status(401).json({ message: 'Unauthorized' }) } 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/server/src/env.ts b/apps/server/src/env.ts index 11fd2f76..c9040de3 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -48,8 +48,6 @@ const envSchema = z.object({ NX_SENTRY_DSN: z.string().optional(), NX_SENTRY_ENV: z.string().optional(), - NX_LD_SDK_KEY: z.string().default('REPLACE_THIS'), - NX_POLYGON_API_KEY: z.string().default(''), NX_PORT: z.string().default('3333'), diff --git a/apps/workers/src/app/__tests__/finicity.integration.spec.ts b/apps/workers/src/app/__tests__/finicity.integration.spec.ts index b56e632c..de3bac65 100644 --- a/apps/workers/src/app/__tests__/finicity.integration.spec.ts +++ b/apps/workers/src/app/__tests__/finicity.integration.spec.ts @@ -68,8 +68,8 @@ describe('Finicity', () => { userId: user.id, name: 'TEST_FINICITY', type: 'finicity', - finicityInstitutionId: 'REPLACE_THIS', - finicityInstitutionLoginId: 'REPLACE_THIS', + finicityInstitutionId: '101732', + finicityInstitutionLoginId: '6000483842', }, }) 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/env.ts b/apps/workers/src/env.ts index f58963bf..ba2464b9 100644 --- a/apps/workers/src/env.ts +++ b/apps/workers/src/env.ts @@ -22,8 +22,6 @@ const envSchema = z.object({ NX_SENTRY_DSN: z.string().optional(), NX_SENTRY_ENV: z.string().optional(), - NX_LD_SDK_KEY: z.string().default('REPLACE_THIS'), - NX_REDIS_URL: z.string().default('redis://localhost:6379'), NX_POLYGON_API_KEY: z.string().default(''), diff --git a/apps/workers/src/main.ts b/apps/workers/src/main.ts index 35c16cdd..890b16d5 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 */24 * * *' }, // 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/design-system/src/lib/DatePicker/DatePicker.spec.tsx b/libs/design-system/src/lib/DatePicker/DatePicker.spec.tsx index a490e8bc..5ebb18cd 100644 --- a/libs/design-system/src/lib/DatePicker/DatePicker.spec.tsx +++ b/libs/design-system/src/lib/DatePicker/DatePicker.spec.tsx @@ -3,9 +3,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { DatePicker } from './' import { DateTime } from 'luxon' -// Set date to Oct 29, 2021 to keep snapshots consistent -beforeAll(() => jest.useFakeTimers().setSystemTime(new Date('2021-10-29 12:00:00'))) - // DatePicker configuration const minDate = DateTime.now().minus({ years: 2 }) const maxDate = DateTime.now() diff --git a/libs/design-system/src/lib/DatePicker/DatePickerCalendar.tsx b/libs/design-system/src/lib/DatePicker/DatePickerCalendar.tsx index 34f307bc..c9cd8257 100644 --- a/libs/design-system/src/lib/DatePicker/DatePickerCalendar.tsx +++ b/libs/design-system/src/lib/DatePicker/DatePickerCalendar.tsx @@ -73,7 +73,7 @@ export function DatePickerCalendar({