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({