1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 07:25:19 +02:00

Merge remote-tracking branch 'upstream/main' into claim-131-ui

This commit is contained in:
Brian Skinner 2024-01-19 11:48:54 -08:00
commit 5764618b0d
72 changed files with 1482 additions and 423 deletions

View file

@ -14,6 +14,7 @@ NX_NEXTAUTH_URL=http://localhost:4200
# We use ngrok to expose a local development environment to the internet # We use ngrok to expose a local development environment to the internet
# You can sign up for a free account and get an API key at https://ngrok.com # You can sign up for a free account and get an API key at https://ngrok.com
NGROK_AUTH_TOKEN= NGROK_AUTH_TOKEN=
NGROK_DOMAIN=
######################################################################## ########################################################################
# DATABASE # DATABASE
@ -35,7 +36,8 @@ NX_POLYGON_API_KEY=
# We use Teller.io for automated banking data. You can sign up for a free # 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 # account and get a free API key at https://teller.io
NX_TELLER_SIGNING_SECRET= NX_TELLER_SIGNING_SECRET=
NX_TELLER_APP_ID= NEXT_PUBLIC_TELLER_APP_ID=
NEXT_PUBLIC_TELLER_ENV=sandbox
NX_TELLER_ENV=sandbox NX_TELLER_ENV=sandbox
######################################################################## ########################################################################

View file

@ -8,5 +8,7 @@
}, },
"[html]": { "[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
} },
"typescript.enablePromptUseWorkspaceTsdk": true,
"typescript.tsdk": "node_modules/typescript/lib"
} }

View file

@ -4,10 +4,6 @@
<b>Get involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybe.co) • [Issues](https://github.com/maybe-finance/maybe/issues)</b> <b>Get involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybe.co) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
🚨 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 ## 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). 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,11 +33,12 @@ As a personal finance + wealth management app, Maybe has a lot of features. Here
And dozens upon dozens of smaller features. 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. *You'll need Docker installed to run the app locally.*
[Docker Desktop](https://www.docker.com/products/docker-desktop/) is an easy way to get started.
First, copy the `.env.example` file to `.env`: First, copy the `.env.example` file to `.env`:
@ -53,6 +50,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. 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: Then run the following yarn commands:
``` ```
@ -63,6 +68,27 @@ yarn prisma:seed
yarn dev yarn dev
``` ```
## Set Up Ngrok
External data providers require HTTPS/SSL webhook URLs for sending data.
To test this locally/during development, you will need to setup `ngrok`.
1. Visit [ngrok.com](https://ngrok.com/)
2. Create a free account
3. Visit [this page](https://dashboard.ngrok.com/get-started/your-authtoken) to access your auth token
4. Paste it into your `.env` file: `NGROK_AUTH_TOKEN=your_auth_token`
You should claim your free static domain to avoid needing to change the URL each time you start/stop the server.
To do so:
1. Visit the [domains](https://dashboard.ngrok.com/cloud-edge/domains) page
2. Click on Create Domain
3. Copy the domain and paste it into your `.env` file: `NGROK_DOMAIN=your_domain`
That's it! As long as you run the project locally using `docker` with `yarn dev:services:all` you'll be good to go.
## Contributing ## Contributing
To contribute, please see our [contribution guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md). To contribute, please see our [contribution guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md).

View file

@ -85,7 +85,6 @@ export const authOptions = {
strategy: 'jwt' as SessionStrategy, strategy: 'jwt' as SessionStrategy,
maxAge: 1 * 24 * 60 * 60, // 1 Day maxAge: 1 * 24 * 60 * 60, // 1 Day
}, },
providers: [ providers: [
CredentialsProvider({ CredentialsProvider({
name: 'Credentials', name: 'Credentials',

View file

@ -57,6 +57,7 @@ export default function LoginPage() {
<form className="space-y-4 w-full px-4" onSubmit={onSubmit}> <form className="space-y-4 w-full px-4" onSubmit={onSubmit}>
<Input <Input
type="text" type="text"
name="email"
label="Email" label="Email"
value={email} value={email}
onChange={(e) => setEmail(e.currentTarget.value)} onChange={(e) => setEmail(e.currentTarget.value)}
@ -64,6 +65,7 @@ export default function LoginPage() {
<InputPassword <InputPassword
autoComplete="password" autoComplete="password"
name="password"
label="Password" label="Password"
value={password} value={password}
showPasswordRequirements={!isValid} showPasswordRequirements={!isValid}

View file

@ -63,18 +63,21 @@ export default function RegisterPage() {
<form className="space-y-4 w-full px-4" onSubmit={onSubmit}> <form className="space-y-4 w-full px-4" onSubmit={onSubmit}>
<Input <Input
type="text" type="text"
name="firstName"
label="First name" label="First name"
value={firstName} value={firstName}
onChange={(e) => setFirstName(e.currentTarget.value)} onChange={(e) => setFirstName(e.currentTarget.value)}
/> />
<Input <Input
type="text" type="text"
name="lastName"
label="Last name" label="Last name"
value={lastName} value={lastName}
onChange={(e) => setLastName(e.currentTarget.value)} onChange={(e) => setLastName(e.currentTarget.value)}
/> />
<Input <Input
type="text" type="text"
name="email"
label="Email" label="Email"
value={email} value={email}
onChange={(e) => setEmail(e.currentTarget.value)} onChange={(e) => setEmail(e.currentTarget.value)}
@ -82,6 +85,7 @@ export default function RegisterPage() {
<InputPassword <InputPassword
autoComplete="password" autoComplete="password"
name="password"
label="Password" label="Password"
value={password} value={password}
showPasswordRequirements={!isValid} showPasswordRequirements={!isValid}

View file

@ -35,7 +35,7 @@ export default function SettingsPage() {
<Tab.List> <Tab.List>
<Tab>Details</Tab> <Tab>Details</Tab>
<Tab>Security</Tab> <Tab>Security</Tab>
<Tab>Billing</Tab> {process.env.STRIPE_API_KEY && <Tab>Billing</Tab>}
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel> <Tab.Panel>
@ -46,9 +46,11 @@ export default function SettingsPage() {
<SecurityPreferences /> <SecurityPreferences />
</div> </div>
</Tab.Panel> </Tab.Panel>
<Tab.Panel> {process.env.STRIPE_API_KEY && (
<BillingPreferences /> <Tab.Panel>
</Tab.Panel> <BillingPreferences />
</Tab.Panel>
)}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</section> </section>

View file

@ -11,14 +11,10 @@ export default defineConfig({
baseUrl: 'http://localhost:4200', baseUrl: 'http://localhost:4200',
env: { env: {
API_URL: 'http://localhost:3333/v1', API_URL: 'http://localhost:3333/v1',
AUTH0_ID: 'REPLACE_THIS', NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
AUTH0_CLIENT_ID: 'REPLACE_THIS', NEXT_PUBLIC_NEXTAUTH_URL: 'http://localhost:4200',
AUTH0_NAME: 'Engineering CI', NEXTAUTH_URL: process.env.NEXTAUTH_URL,
AUTH0_EMAIL: 'REPLACE_THIS', STRIPE_WEBHOOK_SECRET: 'REPLACE_THIS',
AUTH0_PASSWORD: 'REPLACE_THIS',
AUTH0_DOMAIN: 'REPLACE_THIS',
STRIPE_WEBHOOK_SECRET:
'REPLACE_THIS',
STRIPE_CUSTOMER_ID: 'REPLACE_THIS', STRIPE_CUSTOMER_ID: 'REPLACE_THIS',
STRIPE_SUBSCRIPTION_ID: 'REPLACE_THIS', STRIPE_SUBSCRIPTION_ID: 'REPLACE_THIS',
}, },

View file

@ -19,81 +19,6 @@ function openEditAccountModal() {
} }
describe('Accounts', () => { describe('Accounts', () => {
it('should sync and edit a plaid connection', () => {
cy.apiRequest({
method: 'POST',
url: 'e2e/plaid/connect',
}).then((response) => {
expect(response.status).to.eql(200)
// The only time we need to manually send a Plaid webhook is in Github actions when testing a PR
if (Cypress.env('WEBHOOK_TYPE') === 'mock') {
const { plaidItemId } = response.body.data.json
cy.request({
method: 'POST',
url: `${Cypress.env('API_URL')}/plaid/webhook`,
body: {
webhook_type: 'TRANSACTIONS',
webhook_code: 'HISTORICAL_UPDATE',
item_id: plaidItemId,
},
})
.its('status')
.should('equal', 200)
}
})
// Check account sidebar names and balances
cy.visit('/accounts')
cy.getByTestId('account-group', { timeout: 20_000 })
assertSidebarAccounts([
['Assets', '$20,000'],
['Cash', '$20,000'],
['Sandbox Savings', '$15,000'],
['Sandbox Checking', '$5,000'],
['Debts', '$950'],
['Credit Cards', '$950'],
['Sandbox CC', '$950'],
])
// Check current net worth
cy.visit('/')
cy.getByTestId('current-data-value').should('contain.text', '$19,050.00')
// Visit each account page, edit details, re-validate amounts
cy.contains('a', 'Sandbox Checking').click()
cy.getByTestId('current-data-value').should('contain.text', '$5,000.00')
cy.contains('a', 'Sandbox Savings').click()
cy.getByTestId('current-data-value').should('contain.text', '$15,000.00')
cy.contains('a', 'Sandbox CC').click()
cy.getByTestId('current-data-value').should('contain.text', '$950.00')
openEditAccountModal()
cy.getByTestId('connected-account-form').within(() => {
cy.get('input[name="name"]')
.should('have.value', 'Sandbox CC')
.clear()
.type('Credit Credit')
cy.get('input[name="categoryUser"]').should('have.value', 'credit')
cy.get('input[name="startDate"]')
.should('have.value', '')
.type(DateTime.now().minus({ months: 1 }).toFormat('MMddyyyy'))
cy.root().submit()
})
// Should be able to submit empty start date on connected account
openEditAccountModal()
cy.getByTestId('connected-account-form').within(() => {
cy.get('input[name="startDate"]').clear()
cy.root().submit()
})
})
it('should interpolate and display manual vehicle account data', () => { it('should interpolate and display manual vehicle account data', () => {
cy.getByTestId('add-account-button').click() cy.getByTestId('add-account-button').click()
cy.contains('h4', 'Add account') cy.contains('h4', 'Add account')
@ -149,6 +74,8 @@ describe('Accounts', () => {
cy.contains('h4', 'Add real estate') cy.contains('h4', 'Add real estate')
// Details // Details
cy.contains('label', 'Country').click()
cy.contains('button', 'Uganda').click()
cy.get('input[name="line1"]').focus().type('123 Example St') cy.get('input[name="line1"]').focus().type('123 Example St')
cy.get('input[name="city"]').type('New York') cy.get('input[name="city"]').type('New York')
cy.get('input[name="state"]').type('NY') cy.get('input[name="state"]').type('NY')
@ -187,6 +114,9 @@ describe('Accounts', () => {
openEditAccountModal() openEditAccountModal()
cy.getByTestId('property-form').within(() => { cy.getByTestId('property-form').within(() => {
cy.get('input[name="country"]').should('have.value', 'UG')
cy.contains('label', 'Country').click()
cy.contains('button', 'United States').click()
cy.get('input[name="line1"]') cy.get('input[name="line1"]')
.should('have.value', '123 Example St') .should('have.value', '123 Example St')
.clear() .clear()

View file

@ -0,0 +1,9 @@
describe('Auth', () => {
beforeEach(() => cy.visit('/'))
describe('Logging in', () => {
it('should show the home page of an authenticated user', () => {
cy.contains('h5', 'Assets & Debts')
})
})
})

View file

@ -1,17 +1,13 @@
import jwtDecode from 'jwt-decode' // eslint-disable-next-line @typescript-eslint/no-namespace
declare namespace Cypress {
declare global { // eslint-disable-next-line @typescript-eslint/no-unused-vars
// eslint-disable-next-line @typescript-eslint/no-namespace interface Chainable<Subject> {
namespace Cypress { login(): Chainable<any>
// eslint-disable-next-line @typescript-eslint/no-unused-vars apiRequest(...params: Parameters<typeof cy.request>): Chainable<any>
interface Chainable<Subject> { getByTestId(...parameters: Parameters<typeof cy.get>): Chainable<any>
login(username: string, password: string): Chainable<any> selectDate(date: Date): Chainable<any>
apiRequest(...params: Parameters<typeof cy.request>): Chainable<any> preserveAccessToken(): Chainable<any>
getByTestId(...parameters: Parameters<typeof cy.get>): Chainable<any> restoreAccessToken(): Chainable<any>
selectDate(date: Date): Chainable<any>
preserveAccessToken(): Chainable<any>
restoreAccessToken(): Chainable<any>
}
} }
} }
@ -20,13 +16,10 @@ Cypress.Commands.add('getByTestId', (testId, ...rest) => {
}) })
Cypress.Commands.add('apiRequest', ({ url, headers = {}, ...options }, ...rest) => { Cypress.Commands.add('apiRequest', ({ url, headers = {}, ...options }, ...rest) => {
const accessToken = window.localStorage.getItem('token')
return cy.request( return cy.request(
{ {
url: `${Cypress.env('API_URL')}/${url}`, url: `${Cypress.env('API_URL')}/${url}`,
headers: { headers: {
Authorization: `Bearer ${accessToken}`,
...headers, ...headers,
}, },
...options, ...options,
@ -35,74 +28,11 @@ Cypress.Commands.add('apiRequest', ({ url, headers = {}, ...options }, ...rest)
) )
}) })
/** Cypress.Commands.add('login', () => {
* Logs in with the engineering CI account cy.visit('/login')
*/ cy.get('input[name="email"]').type('bond@007.com')
Cypress.Commands.add('login', (username, password) => { cy.get('input[name="password"]').type('TestPassword123')
// Preserves login across tests cy.get('button[type="submit"]').click()
cy.session('login-session-key', login, { //eslint-disable-next-line cypress/no-unnecessary-waiting
validate() { cy.wait(1000)
cy.apiRequest({ url: 'e2e' }).its('status').should('eq', 200)
},
})
function login() {
const client_id = Cypress.env('AUTH0_CLIENT_ID')
const audience = 'https://maybe-finance-api/v1'
const scope = 'openid profile email offline_access'
const accessTokenStorageKey = `@@auth0spajs@@::${client_id}::${audience}::${scope}`
const AUTH_DOMAIN = Cypress.env('AUTH0_DOMAIN')
cy.log(`Logging in as ${username}`)
/**
* Uses the official Cypress Auth0 strategy for testing with Auth0
* https://docs.cypress.io/guides/testing-strategies/auth0-authentication#Auth0-Application-Setup
*
* Relevant Auth0 endpoint
* https://auth0.com/docs/api/authentication?javascript#resource-owner-password
*/
cy.request({
method: 'POST',
url: `https://${AUTH_DOMAIN}/oauth/token`,
body: {
grant_type: 'password',
username,
password,
audience,
scope,
client_id,
},
}).then(({ body }) => {
const claims = jwtDecode<any>(body.id_token)
const { nickname, name, picture, updated_at, email, email_verified, sub, exp } = claims
const item = {
body: {
...body,
decodedToken: {
claims,
user: {
nickname,
name,
picture,
updated_at,
email,
email_verified,
sub,
},
audience,
client_id,
},
},
expiresAt: exp,
}
window.localStorage.setItem(accessTokenStorageKey, JSON.stringify(item))
window.localStorage.setItem('token', body.access_token)
cy.visit('/')
})
}
}) })

View file

@ -2,8 +2,7 @@ import './commands'
beforeEach(() => { beforeEach(() => {
// Login // Login
// Rate limit 30 / min - https://auth0.com/docs/troubleshoot/customer-support/operational-policies/rate-limit-policy#limits-for-non-production-tenants-of-paying-customers-and-all-tenants-of-free-customers cy.login()
cy.login(Cypress.env('AUTH0_EMAIL'), Cypress.env('AUTH0_PASSWORD'))
// Delete the current user to wipe all data before test // Delete the current user to wipe all data before test
cy.apiRequest({ cy.apiRequest({
@ -14,6 +13,6 @@ beforeEach(() => {
expect(response.status).to.equal(200) expect(response.status).to.equal(200)
}) })
// Re-login (JWT should still be valid) // Go back to dashboard
cy.visit('/') cy.visit('/')
}) })

View file

@ -0,0 +1,2 @@
import './commands'
import './e2e'

View file

@ -9,7 +9,6 @@ import {
} from '@maybe-finance/server/features' } from '@maybe-finance/server/features'
import { InMemoryQueueFactory, PgService, type IQueueFactory } from '@maybe-finance/server/shared' import { InMemoryQueueFactory, PgService, type IQueueFactory } from '@maybe-finance/server/shared'
import { createLogger, transports } from 'winston' import { createLogger, transports } from 'winston'
import isCI from 'is-ci'
import nock from 'nock' import nock from 'nock'
import Decimal from 'decimal.js' import Decimal from 'decimal.js'
import { startServer, stopServer } from './utils/server' import { startServer, stopServer } from './utils/server'
@ -27,7 +26,7 @@ const prisma = new PrismaClient()
// For TypeScript support // For TypeScript support
const plaid = jest.mocked(_plaid) // eslint-disable-line const plaid = jest.mocked(_plaid) // eslint-disable-line
const auth0Id = isCI ? 'auth0|61afd38f678a0c006895f046' : 'auth0|61afd340678a0c006895f000' const authId = '__TEST_USER_ID__'
let axios: AxiosInstance let axios: AxiosInstance
let user: User let user: User
@ -38,7 +37,7 @@ if (process.env.IS_VSCODE_DEBUG === 'true') {
beforeEach(async () => { beforeEach(async () => {
// Clears old user and data, creates new user // Clears old user and data, creates new user
user = await resetUser(auth0Id) user = await resetUser(authId)
}) })
describe('/v1/accounts API', () => { describe('/v1/accounts API', () => {

View file

@ -2,7 +2,6 @@ import type { AxiosInstance } from 'axios'
import type { SharedType } from '@maybe-finance/shared' import type { SharedType } from '@maybe-finance/shared'
import type { Prisma, AccountConnection, AccountSyncStatus, User } from '@prisma/client' import type { Prisma, AccountConnection, AccountSyncStatus, User } from '@prisma/client'
import type { ItemRemoveResponse } from 'plaid' import type { ItemRemoveResponse } from 'plaid'
import isCI from 'is-ci'
import { startServer, stopServer } from './utils/server' import { startServer, stopServer } from './utils/server'
import { getAxiosClient } from './utils/axios' import { getAxiosClient } from './utils/axios'
import prisma from '../lib/prisma' import prisma from '../lib/prisma'
@ -18,7 +17,7 @@ jest.mock('plaid')
// For TypeScript support // For TypeScript support
const plaid = jest.mocked(_plaid) const plaid = jest.mocked(_plaid)
const auth0Id = isCI ? 'auth0|61afd38f678a0c006895f046' : 'auth0|61afd340678a0c006895f000' const authId = '__TEST_USER_ID__'
let axios: AxiosInstance let axios: AxiosInstance
let user: User | null let user: User | null
let connection: AccountConnection let connection: AccountConnection
@ -45,7 +44,7 @@ afterAll(async () => {
}) })
beforeEach(async () => { beforeEach(async () => {
user = await resetUser(auth0Id) user = await resetUser(authId)
connectionData = { connectionData = {
data: { data: {

View file

@ -1,38 +1,37 @@
import type { AxiosResponse } from 'axios' import type { AxiosResponse } from 'axios'
import type { SharedType } from '@maybe-finance/shared' import type { SharedType } from '@maybe-finance/shared'
import { superjson } from '@maybe-finance/shared' import { superjson } from '@maybe-finance/shared'
import env from '../../../env'
import isCI from 'is-ci'
import Axios from 'axios' 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() { export async function getAxiosClient() {
const tenantUrl = isCI const baseUrl = 'http://127.0.0.1:53333/v1'
? 'REPLACE_THIS-staging.us.auth0.com' const jwt = await encode({
: 'REPLACE_THIS-development.us.auth0.com' maxAge: 1 * 24 * 60 * 60,
secret: process.env.NEXTAUTH_SECRET || 'CHANGE_ME',
const { token: {
data: { access_token: token }, sub: '__TEST_USER_ID__',
} = await Axios.request({ user: '__TEST_USER_ID__',
method: 'POST', 'https://maybe.co/email': 'REPLACE_THIS',
url: `https://${tenantUrl}/oauth/token`, firstName: 'REPLACE_THIS',
headers: { 'content-type': 'application/json' }, lastName: 'REPLACE_THIS',
data: { name: 'REPLACE_THIS',
grant_type: 'password',
username: 'REPLACE_THIS',
password: 'REPLACE_THIS',
audience: 'https://maybe-finance-api/v1',
scope: '',
client_id: isCI ? 'REPLACE_THIS' : '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({ const axios = Axios.create({
baseURL: 'http://127.0.0.1:53333/v1', ...axiosOptions,
validateStatus: () => true, // Tests should determine whether status is correct, not Axios validateStatus: () => true, // Tests should determine whether status is correct, not Axios
headers: {
Authorization: `Bearer ${token}`,
},
}) })
axios.interceptors.response.use((response: AxiosResponse<SharedType.BaseResponse>) => { axios.interceptors.response.use((response: AxiosResponse<SharedType.BaseResponse>) => {

View file

@ -36,6 +36,7 @@ import {
valuationsRouter, valuationsRouter,
institutionsRouter, institutionsRouter,
finicityRouter, finicityRouter,
tellerRouter,
transactionsRouter, transactionsRouter,
holdingsRouter, holdingsRouter,
securitiesRouter, securitiesRouter,
@ -43,6 +44,7 @@ import {
toolsRouter, toolsRouter,
publicRouter, publicRouter,
e2eRouter, e2eRouter,
adminRouter,
} from './routes' } from './routes'
import env from '../env' import env from '../env'
@ -92,6 +94,7 @@ app.use(cors({ origin, credentials: true }))
app.options('*', cors() as RequestHandler) app.options('*', cors() as RequestHandler)
app.set('view engine', 'ejs').set('views', __dirname + '/app/admin/views') app.set('view engine', 'ejs').set('views', __dirname + '/app/admin/views')
app.use('/admin', adminRouter)
app.use( app.use(
morgan(env.NX_MORGAN_LOG_LEVEL, { morgan(env.NX_MORGAN_LOG_LEVEL, {
@ -156,6 +159,7 @@ app.use('/v1/users', usersRouter)
app.use('/v1/e2e', e2eRouter) app.use('/v1/e2e', e2eRouter)
app.use('/v1/plaid', plaidRouter) app.use('/v1/plaid', plaidRouter)
app.use('/v1/finicity', finicityRouter) app.use('/v1/finicity', finicityRouter)
app.use('/v1/teller', tellerRouter)
app.use('/v1/accounts', accountsRouter) app.use('/v1/accounts', accountsRouter)
app.use('/v1/account-rollup', accountRollupRouter) app.use('/v1/account-rollup', accountRollupRouter)
app.use('/v1/connections', connectionsRouter) app.use('/v1/connections', connectionsRouter)

View file

@ -240,6 +240,7 @@ const userService = new UserService(
const institutionProviderFactory = new InstitutionProviderFactory({ const institutionProviderFactory = new InstitutionProviderFactory({
PLAID: plaidService, PLAID: plaidService,
FINICITY: finicityService, FINICITY: finicityService,
TELLER: tellerService,
}) })
const institutionService: IInstitutionService = new InstitutionService( const institutionService: IInstitutionService = new InstitutionService(

View file

@ -2,17 +2,20 @@ import cookieParser from 'cookie-parser'
import { decode } from 'next-auth/jwt' import { decode } from 'next-auth/jwt'
const SECRET = process.env.NEXTAUTH_SECRET ?? 'REPLACE_THIS' const SECRET = process.env.NEXTAUTH_SECRET ?? 'REPLACE_THIS'
export const validateAuthJwt = async (req, res, next) => { export const validateAuthJwt = async (req, res, next) => {
cookieParser(SECRET)(req, res, async (err) => { cookieParser(SECRET)(req, res, async (err) => {
if (err) { if (err) {
return res.status(500).json({ message: 'Internal Server Error' }) 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 { try {
const token = await decode({ const token = await decode({
token: req.cookies['next-auth.session-token'], token: req.cookies[cookieName],
secret: SECRET, secret: SECRET,
}) })
@ -26,6 +29,18 @@ export const validateAuthJwt = async (req, res, next) => {
console.error('Error in token validation', error) console.error('Error in token validation', error)
return res.status(500).json({ message: 'Internal Server 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 { } else {
return res.status(401).json({ message: 'Unauthorized' }) return res.status(401).json({ message: 'Unauthorized' })
} }

View file

@ -0,0 +1,33 @@
import { Router } from 'express'
import { auth, claimCheck } from 'express-openid-connect'
import { createBullBoard } from '@bull-board/api'
import { BullAdapter } from '@bull-board/api/bullAdapter'
import { ExpressAdapter } from '@bull-board/express'
import { AuthUtil, BullQueue } from '@maybe-finance/server/shared'
import { SharedType } from '@maybe-finance/shared'
import { queueService } from '../lib/endpoint'
import env from '../../env'
import { validateAuthJwt } from '../middleware'
const router = Router()
const serverAdapter = new ExpressAdapter().setBasePath('/admin/bullmq')
createBullBoard({
queues: queueService.allQueues
.filter((q): q is BullQueue => q instanceof BullQueue)
.map((q) => new BullAdapter(q.queue)),
serverAdapter,
})
router.get('/', validateAuthJwt, (req, res) => {
res.render('pages/dashboard', {
user: req.user?.name,
role: 'Admin',
})
})
// Visit /admin/bullmq to see BullMQ Dashboard
router.use('/bullmq', validateAuthJwt, serverAdapter.getRouter())
export default router

View file

@ -6,13 +6,13 @@ import endpoint from '../lib/endpoint'
const router = Router() const router = Router()
router.use((req, res, next) => { const testUserId = 'test_ec3ee8a4-fa01-4f11-8ac5-9c49dd7fbae4'
const roles = req.user?.['https://maybe.co/roles']
if (roles?.includes('CIUser') || roles?.includes('Admin')) { router.use((req, res, next) => {
if (req.user?.sub === testUserId) {
next() next()
} else { } else {
res.status(401).send('Route only available to CIUser and Admin roles') res.status(401).send('Route only available to test users')
} }
}) })
@ -47,14 +47,14 @@ router.post(
trialLapsed: z.boolean().default(false), trialLapsed: z.boolean().default(false),
}), }),
resolve: async ({ ctx, input }) => { resolve: async ({ ctx, input }) => {
ctx.logger.debug(`Resetting CI user ${ctx.user!.authId}`)
await ctx.prisma.$transaction([ await ctx.prisma.$transaction([
ctx.prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${ctx.user!.authId};`, ctx.prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${testUserId};`,
ctx.prisma.user.create({ ctx.prisma.user.create({
data: { data: {
authId: ctx.user!.authId, authId: testUserId,
email: 'REPLACE_THIS', email: 'bond@007.com',
firstName: 'James',
lastName: 'Bond',
dob: new Date('1990-01-01'), dob: new Date('1990-01-01'),
linkAccountDismissedAt: new Date(), // ensures our auto-account link doesn't trigger linkAccountDismissedAt: new Date(), // ensures our auto-account link doesn't trigger

View file

@ -5,6 +5,7 @@ export { default as usersRouter } from './users.router'
export { default as webhooksRouter } from './webhooks.router' export { default as webhooksRouter } from './webhooks.router'
export { default as plaidRouter } from './plaid.router' export { default as plaidRouter } from './plaid.router'
export { default as finicityRouter } from './finicity.router' export { default as finicityRouter } from './finicity.router'
export { default as tellerRouter } from './teller.router'
export { default as valuationsRouter } from './valuations.router' export { default as valuationsRouter } from './valuations.router'
export { default as institutionsRouter } from './institutions.router' export { default as institutionsRouter } from './institutions.router'
export { default as transactionsRouter } from './transactions.router' export { default as transactionsRouter } from './transactions.router'
@ -14,3 +15,4 @@ export { default as plansRouter } from './plans.router'
export { default as toolsRouter } from './tools.router' export { default as toolsRouter } from './tools.router'
export { default as publicRouter } from './public.router' export { default as publicRouter } from './public.router'
export { default as e2eRouter } from './e2e.router' export { default as e2eRouter } from './e2e.router'
export { default as adminRouter } from './admin.router'

View file

@ -0,0 +1,45 @@
import { Router } from 'express'
import { z } from 'zod'
import endpoint from '../lib/endpoint'
const router = Router()
router.post(
'/handle-enrollment',
endpoint.create({
input: z.object({
institution: z.object({
name: z.string(),
id: z.string(),
}),
enrollment: z.object({
accessToken: z.string(),
user: z.object({
id: z.string(),
}),
enrollment: z.object({
id: z.string(),
institution: z.object({
name: z.string(),
}),
}),
signatures: z.array(z.string()).optional(),
}),
}),
resolve: ({ input: { institution, enrollment }, ctx }) => {
return ctx.tellerService.handleEnrollment(ctx.user!.id, institution, enrollment)
},
})
)
router.post(
'/institutions/sync',
endpoint.create({
resolve: async ({ ctx }) => {
ctx.ability.throwUnlessCan('manage', 'Institution')
await ctx.queueService.getQueue('sync-institution').add('sync-teller-institutions', {})
},
})
)
export default router

View file

@ -48,8 +48,6 @@ const envSchema = z.object({
NX_SENTRY_DSN: z.string().optional(), NX_SENTRY_DSN: z.string().optional(),
NX_SENTRY_ENV: 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_POLYGON_API_KEY: z.string().default(''),
NX_PORT: z.string().default('3333'), NX_PORT: z.string().default('3333'),

View file

@ -68,8 +68,8 @@ describe('Finicity', () => {
userId: user.id, userId: user.id,
name: 'TEST_FINICITY', name: 'TEST_FINICITY',
type: 'finicity', type: 'finicity',
finicityInstitutionId: 'REPLACE_THIS', finicityInstitutionId: '101732',
finicityInstitutionLoginId: 'REPLACE_THIS', finicityInstitutionLoginId: '6000483842',
}, },
}) })

View file

@ -1,22 +1,28 @@
import type { PrismaClient, User } from '@prisma/client' import type { PrismaClient, User } from '@prisma/client'
import { faker } from '@faker-js/faker'
export async function resetUser(prisma: PrismaClient, authId = 'TODO'): Promise<User> { export async function resetUser(prisma: PrismaClient, authId = '__TEST_USER_ID__'): Promise<User> {
// eslint-disable-next-line try {
const [_, __, ___, user] = await prisma.$transaction([ // eslint-disable-next-line
prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${authId};`, 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 // Deleting a user does not cascade to securities, so delete all security records
prisma.$executeRaw`DELETE from security;`, prisma.$executeRaw`DELETE from security;`,
prisma.$executeRaw`DELETE from security_pricing;`, prisma.$executeRaw`DELETE from security_pricing;`,
prisma.user.create({ prisma.user.create({
data: { data: {
authId, authId,
email: 'test@example.com', email: faker.internet.email(),
finicityCustomerId: 'TEST', finicityCustomerId: faker.string.uuid(),
}, tellerUserId: faker.string.uuid(),
}), },
]) }),
])
return user return user
} catch (e) {
console.error('error in reset user transaction', e)
throw e
}
} }

View file

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

View file

@ -259,6 +259,7 @@ export const securityPricingProcessor: ISecurityPricingProcessor = new SecurityP
const institutionProviderFactory = new InstitutionProviderFactory({ const institutionProviderFactory = new InstitutionProviderFactory({
PLAID: plaidService, PLAID: plaidService,
FINICITY: finicityService, FINICITY: finicityService,
TELLER: tellerService,
}) })
export const institutionService: IInstitutionService = new InstitutionService( export const institutionService: IInstitutionService = new InstitutionService(

View file

@ -22,8 +22,6 @@ const envSchema = z.object({
NX_SENTRY_DSN: z.string().optional(), NX_SENTRY_DSN: z.string().optional(),
NX_SENTRY_ENV: 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_REDIS_URL: z.string().default('redis://localhost:6379'),
NX_POLYGON_API_KEY: z.string().default(''), NX_POLYGON_API_KEY: z.string().default(''),
@ -35,6 +33,8 @@ const envSchema = z.object({
NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'), NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'),
NX_CDN_PUBLIC_BUCKET: z.string().default('REPLACE_THIS'), NX_CDN_PUBLIC_BUCKET: z.string().default('REPLACE_THIS'),
STRIPE_API_KEY: z.string().optional(),
}) })
const env = envSchema.parse(process.env) const env = envSchema.parse(process.env)

View file

@ -16,6 +16,7 @@ import {
workerErrorHandlerService, workerErrorHandlerService,
} from './app/lib/di' } from './app/lib/di'
import env from './env' import env from './env'
import { cleanUpOutdatedJobs } from './utils'
// Defaults from quickstart - https://docs.sentry.io/platforms/node/ // Defaults from quickstart - https://docs.sentry.io/platforms/node/
Sentry.init({ Sentry.init({
@ -99,6 +100,7 @@ syncSecurityQueue.add(
{}, {},
{ {
repeat: { cron: '*/5 * * * *' }, // Run every 5 minutes repeat: { cron: '*/5 * * * *' }, // Run every 5 minutes
jobId: Date.now().toString(),
} }
) )
@ -115,11 +117,17 @@ syncInstitutionQueue.process(
async () => await institutionService.sync('FINICITY') async () => await institutionService.sync('FINICITY')
) )
syncInstitutionQueue.process(
'sync-teller-institutions',
async () => await institutionService.sync('TELLER')
)
syncInstitutionQueue.add( syncInstitutionQueue.add(
'sync-plaid-institutions', 'sync-plaid-institutions',
{}, {},
{ {
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
jobId: Date.now().toString(),
} }
) )
@ -128,6 +136,16 @@ syncInstitutionQueue.add(
{}, {},
{ {
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
jobId: Date.now().toString(),
}
)
syncInstitutionQueue.add(
'sync-teller-institutions',
{},
{
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
jobId: Date.now().toString(),
} }
) )
@ -136,11 +154,13 @@ syncInstitutionQueue.add(
*/ */
sendEmailQueue.process('send-email', async (job) => await emailProcessor.send(job.data)) sendEmailQueue.process('send-email', async (job) => await emailProcessor.send(job.data))
sendEmailQueue.add( if (env.STRIPE_API_KEY) {
'send-email', sendEmailQueue.add(
{ type: 'trial-reminders' }, 'send-email',
{ repeat: { cron: '0 */12 * * *' } } // Run every 12 hours { type: 'trial-reminders' },
) { repeat: { cron: '0 */12 * * *' } } // Run every 12 hours
)
}
// Fallback - usually triggered by errors not handled (or thrown) within the Bull event handlers (see above) // Fallback - usually triggered by errors not handled (or thrown) within the Bull event handlers (see above)
process.on( process.on(
@ -156,6 +176,11 @@ process.on(
await workerErrorHandlerService.handleWorkersError({ variant: 'unhandled', error }) await workerErrorHandlerService.handleWorkersError({ variant: 'unhandled', error })
) )
// Replace any jobs that have changed cron schedules and ensures only
// one repeatable jobs for each type is running
const queues = [syncSecurityQueue, syncInstitutionQueue]
cleanUpOutdatedJobs(queues)
const app = express() const app = express()
app.use(cors()) app.use(cors())
@ -181,20 +206,24 @@ const server = app.listen(env.NX_PORT, () => {
logger.info(`Worker health server started on port ${env.NX_PORT}`) logger.info(`Worker health server started on port ${env.NX_PORT}`)
}) })
function onShutdown() { async function onShutdown() {
logger.info('[shutdown.start]') logger.info('[shutdown.start]')
server.close() await new Promise((resolve) => server.close(resolve))
// shutdown queues // shutdown queues
Promise.allSettled( try {
queueService.allQueues await Promise.allSettled(
.filter((q): q is BullQueue => q instanceof BullQueue) queueService.allQueues
.map((q) => q.queue.close()) .filter((q): q is BullQueue => q instanceof BullQueue)
).finally(() => { .map((q) => q.queue.close())
)
} catch (error) {
logger.error('[shutdown.error]', error)
} finally {
logger.info('[shutdown.complete]') logger.info('[shutdown.complete]')
process.exit() process.exitCode = 0
}) }
} }
process.on('SIGINT', onShutdown) process.on('SIGINT', onShutdown)

46
apps/workers/src/utils.ts Normal file
View file

@ -0,0 +1,46 @@
import type { IQueue } from '@maybe-finance/server/shared'
import type { JobInformation } from 'bull'
export async function cleanUpOutdatedJobs(queues: IQueue[]) {
for (const queue of queues) {
const repeatedJobs = await queue.getRepeatableJobs()
const outdatedJobs = filterOutdatedJobs(repeatedJobs)
for (const job of outdatedJobs) {
await queue.removeRepeatableByKey(job.key)
}
}
}
function filterOutdatedJobs(jobs: JobInformation[]) {
const jobGroups = new Map()
jobs.forEach((job) => {
if (!jobGroups.has(job.name)) {
jobGroups.set(job.name, [])
}
jobGroups.get(job.name).push(job)
})
const mostRecentJobs = new Map()
jobGroups.forEach((group, name) => {
const mostRecentJob = group.reduce((mostRecent, current) => {
if (current.id === null) return mostRecent
const currentIdTime = current.id
const mostRecentIdTime = mostRecent ? mostRecent.id : 0
return currentIdTime > mostRecentIdTime ? current : mostRecent
}, null)
if (mostRecentJob) {
mostRecentJobs.set(name, mostRecentJob.id)
}
})
return jobs.filter((job: JobInformation) => {
const mostRecentId = mostRecentJobs.get(job.name)
return job.id === null || job.id !== mostRecentId
})
}
export default cleanUpOutdatedJobs

View file

@ -31,7 +31,7 @@ services:
ports: ports:
- 4551:4551 - 4551:4551
environment: environment:
- DOMAIN=host.docker.internal - DOMAIN=${NGROK_DOMAIN:-host.docker.internal}
- PORT=3333 - PORT=3333
- AUTH_TOKEN=${NGROK_AUTH_TOKEN} - AUTH_TOKEN=${NGROK_AUTH_TOKEN}
- DEBUG=true - DEBUG=true

View file

@ -7,12 +7,14 @@ import {
useDebounce, useDebounce,
usePlaid, usePlaid,
useFinicity, useFinicity,
useTellerConfig,
useTellerConnect,
} from '@maybe-finance/client/shared' } from '@maybe-finance/client/shared'
import { Input } from '@maybe-finance/design-system' import { Input } from '@maybe-finance/design-system'
import InstitutionGrid from './InstitutionGrid' import InstitutionGrid from './InstitutionGrid'
import { AccountTypeGrid } from './AccountTypeGrid' import { AccountTypeGrid } from './AccountTypeGrid'
import InstitutionList, { MIN_QUERY_LENGTH } from './InstitutionList' import InstitutionList, { MIN_QUERY_LENGTH } from './InstitutionList'
import { useLogger } from '@maybe-finance/client/shared'
const SEARCH_DEBOUNCE_MS = 300 const SEARCH_DEBOUNCE_MS = 300
@ -23,6 +25,7 @@ export default function AccountTypeSelector({
view: string view: string
onViewChange: (view: string) => void onViewChange: (view: string) => void
}) { }) {
const logger = useLogger()
const { setAccountManager } = useAccountContext() const { setAccountManager } = useAccountContext()
const [searchQuery, setSearchQuery] = useState<string>('') const [searchQuery, setSearchQuery] = useState<string>('')
@ -33,8 +36,11 @@ export default function AccountTypeSelector({
debouncedSearchQuery.length >= MIN_QUERY_LENGTH && debouncedSearchQuery.length >= MIN_QUERY_LENGTH &&
view !== 'manual' view !== 'manual'
const config = useTellerConfig(logger)
const { openPlaid } = usePlaid() const { openPlaid } = usePlaid()
const { openFinicity } = useFinicity() const { openFinicity } = useFinicity()
const { open: openTeller } = useTellerConnect(config, logger)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
@ -77,6 +83,9 @@ export default function AccountTypeSelector({
case 'FINICITY': case 'FINICITY':
openFinicity(providerInstitution.providerId) openFinicity(providerInstitution.providerId)
break break
case 'TELLER':
openTeller(providerInstitution.providerId)
break
default: default:
break break
} }
@ -138,11 +147,12 @@ export default function AccountTypeSelector({
categoryUser: 'crypto', categoryUser: 'crypto',
}, },
}) })
return return
} }
if (!data) return if (!data) {
return
}
switch (data.provider) { switch (data.provider) {
case 'PLAID': case 'PLAID':
@ -151,6 +161,9 @@ export default function AccountTypeSelector({
case 'FINICITY': case 'FINICITY':
openFinicity(data.providerId) openFinicity(data.providerId)
break break
case 'TELLER':
openTeller(data.providerId)
break
default: default:
break break
} }

View file

@ -14,48 +14,48 @@ const banks: GridImage[] = [
src: 'chase-bank.png', src: 'chase-bank.png',
alt: 'Chase Bank', alt: 'Chase Bank',
institution: { institution: {
provider: 'PLAID', provider: 'TELLER',
providerId: 'ins_56', providerId: 'chase',
}, },
}, },
{ {
src: 'capital-one.png', src: 'capital-one.png',
alt: 'Capital One Bank', alt: 'Capital One Bank',
institution: { institution: {
provider: 'PLAID', provider: 'TELLER',
providerId: 'ins_128026', providerId: 'capital_one',
}, },
}, },
{ {
src: 'wells-fargo.png', src: 'wells-fargo.png',
alt: 'Wells Fargo Bank', alt: 'Wells Fargo Bank',
institution: { institution: {
provider: 'PLAID', provider: 'TELLER',
providerId: 'ins_127991', providerId: 'wells_fargo',
}, },
}, },
{ {
src: 'american-express.png', src: 'american-express.png',
alt: 'American Express Bank', alt: 'American Express Bank',
institution: { institution: {
provider: 'PLAID', provider: 'TELLER',
providerId: 'ins_10', providerId: 'amex',
}, },
}, },
{ {
src: 'bofa.png', src: 'bofa.png',
alt: 'Bank of America', alt: 'Bank of America',
institution: { institution: {
provider: 'PLAID', provider: 'TELLER',
providerId: 'ins_127989', providerId: 'bank_of_america',
}, },
}, },
{ {
src: 'usaa-bank.png', src: 'usaa-bank.png',
alt: 'USAA Bank', alt: 'USAA Bank',
institution: { institution: {
provider: 'PLAID', provider: 'TELLER',
providerId: 'ins_7', providerId: 'usaa',
}, },
}, },
] ]

View file

@ -19,6 +19,7 @@ export function AddProperty({ defaultValues }: { defaultValues: Partial<CreatePr
line1: defaultValues.line1 ?? '', line1: defaultValues.line1 ?? '',
city: defaultValues.city ?? '', city: defaultValues.city ?? '',
state: defaultValues.state ?? '', state: defaultValues.state ?? '',
country: defaultValues.country ?? 'US',
zip: defaultValues.zip ?? '', zip: defaultValues.zip ?? '',
startDate: defaultValues.startDate ?? null, startDate: defaultValues.startDate ?? null,
originalBalance: defaultValues.originalBalance ?? null, originalBalance: defaultValues.originalBalance ?? null,
@ -28,6 +29,7 @@ export function AddProperty({ defaultValues }: { defaultValues: Partial<CreatePr
line1, line1,
city, city,
state, state,
country,
zip, zip,
originalBalance, originalBalance,
currentBalance, currentBalance,
@ -48,6 +50,7 @@ export function AddProperty({ defaultValues }: { defaultValues: Partial<CreatePr
line1, line1,
city, city,
state, state,
country,
zip, zip,
}, },
}, },

View file

@ -16,7 +16,7 @@ export function EditProperty({ account }: { account: SharedType.AccountDetail })
<PropertyForm <PropertyForm
mode="update" mode="update"
defaultValues={(account.propertyMeta as any)?.address as UpdatePropertyFields} defaultValues={(account.propertyMeta as any)?.address as UpdatePropertyFields}
onSubmit={async ({ line1, city, state, zip, ...rest }) => { onSubmit={async ({ line1, city, state, country, zip, ...rest }) => {
await updateAccount.mutateAsync({ await updateAccount.mutateAsync({
id: account.id, id: account.id,
data: { data: {
@ -30,6 +30,7 @@ export function EditProperty({ account }: { account: SharedType.AccountDetail })
line1, line1,
city, city,
state, state,
country,
zip, zip,
}, },
}, },

View file

@ -1,7 +1,7 @@
import type { CreatePropertyFields, UpdatePropertyFields } from '@maybe-finance/client/shared' import type { CreatePropertyFields, UpdatePropertyFields } from '@maybe-finance/client/shared'
import { Button, Input } from '@maybe-finance/design-system' import { Button, Input, Listbox } from '@maybe-finance/design-system'
import { DateUtil } from '@maybe-finance/shared' import { DateUtil, Geo } from '@maybe-finance/shared'
import { useForm } from 'react-hook-form' import { Controller, useForm } from 'react-hook-form'
import { AccountValuationFormFields } from '../AccountValuationFormFields' import { AccountValuationFormFields } from '../AccountValuationFormFields'
type Props = type Props =
@ -36,7 +36,26 @@ export default function PropertyForm({ mode, defaultValues, onSubmit }: Props) {
<section className="space-y-4 mb-8"> <section className="space-y-4 mb-8">
<h6 className="text-white uppercase">Location</h6> <h6 className="text-white uppercase">Location</h6>
<div className="space-y-4"> <div className="space-y-4">
<Input type="text" label="Country" value="United States" readOnly disabled /> <Controller
name="country"
rules={{ required: true }}
control={control}
render={({ field }) => (
<Listbox {...field}>
<Listbox.Button label="Country">
{Geo.countries.find((c) => c.code === field.value)?.name ||
'Select'}
</Listbox.Button>
<Listbox.Options className="max-h-[300px] custom-gray-scroll">
{Geo.countries.map((country) => (
<Listbox.Option key={country.code} value={country.code}>
{country.name}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
)}
/>
<Input <Input
type="text" type="text"

View file

@ -313,7 +313,6 @@ function DefaultContent({
email, email,
}: PropsWithChildren<{ onboarding?: ReactNode; name?: string; email?: string }>) { }: PropsWithChildren<{ onboarding?: ReactNode; name?: string; email?: string }>) {
const { addAccount } = useAccountContext() const { addAccount } = useAccountContext()
return ( return (
<> <>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@ -338,7 +337,7 @@ function DefaultContent({
{onboarding && onboarding} {onboarding && onboarding}
<UpgradePrompt /> {process.env.STRIPE_API_KEY && <UpgradePrompt />}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-base"> <div className="text-base">

View file

@ -174,7 +174,7 @@ export function MobileLayout({ children, sidebar }: MobileLayoutProps) {
</section> </section>
<div className="pt-6 shrink-0"> <div className="pt-6 shrink-0">
<UpgradePrompt /> {process.env.STRIPE_API_KEY && <UpgradePrompt />}
</div> </div>
</div> </div>
</div> </div>

View file

@ -78,7 +78,9 @@ export function BillingPreferences() {
</div> </div>
)} )}
</div> </div>
<UpgradeTakeover open={takeoverOpen} onClose={() => setTakeoverOpen(false)} /> {process.env.STRIPE_API_KEY && (
<UpgradeTakeover open={takeoverOpen} onClose={() => setTakeoverOpen(false)} />
)}
</> </>
) )
} }

View file

@ -5,6 +5,7 @@ export * from './useFinicityApi'
export * from './useInstitutionApi' export * from './useInstitutionApi'
export * from './useUserApi' export * from './useUserApi'
export * from './usePlaidApi' export * from './usePlaidApi'
export * from './useTellerApi'
export * from './useValuationApi' export * from './useValuationApi'
export * from './useTransactionApi' export * from './useTransactionApi'
export * from './useHoldingApi' export * from './useHoldingApi'

View file

@ -0,0 +1,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<SharedType.AccountConnection>(
'/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<SharedType.AccountsResponse>(['accounts'])
if (!accountsData)
queryClient.setQueryData<SharedType.AccountsResponse>(['accounts'], {
connections: [{ ...connection, accounts: [] }],
accounts: [],
})
else {
const { connections, ...rest } = accountsData
queryClient.setQueryData<SharedType.AccountsResponse>(['accounts'], {
connections: [...connections, { ...connection, accounts: [] }],
...rest,
})
}
}
const useHandleEnrollment = () =>
useMutation(api.handleEnrollment, {
onSuccess: (_connection) => {
addConnectionToState(_connection)
syncConnection.mutate(_connection.id)
toast.success(`Account connection added!`)
},
})
return {
useHandleEnrollment,
}
}

View file

@ -9,5 +9,6 @@ export * from './useQueryParam'
export * from './useScreenSize' export * from './useScreenSize'
export * from './useAccountNotifications' export * from './useAccountNotifications'
export * from './usePlaid' export * from './usePlaid'
export * from './useTeller'
export * from './useProviderStatus' export * from './useProviderStatus'
export * from './useModalManager' export * from './useModalManager'

View file

@ -0,0 +1,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<TellerConnectInstance | null>(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,
}
}

View file

@ -18,6 +18,7 @@ type PropertyMetadataValues = {
line1: string line1: string
city: string city: string
state: string state: string
country: string
zip: string zip: string
} }
export type CreatePropertyFields = PropertyMetadataValues & AccountValuationFields export type CreatePropertyFields = PropertyMetadataValues & AccountValuationFields
@ -50,6 +51,7 @@ type AccountManager =
| { view: 'idle' } | { view: 'idle' }
| { view: 'add-plaid'; linkToken: string } | { view: 'add-plaid'; linkToken: string }
| { view: 'add-finicity' } | { view: 'add-finicity' }
| { view: 'add-teller' }
| { view: 'add-account' } | { view: 'add-account' }
| { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> } | { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> }
| { view: 'add-vehicle'; defaultValues: Partial<CreateVehicleFields> } | { view: 'add-vehicle'; defaultValues: Partial<CreateVehicleFields> }

View file

@ -3,9 +3,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { DatePicker } from './' import { DatePicker } from './'
import { DateTime } from 'luxon' 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 // DatePicker configuration
const minDate = DateTime.now().minus({ years: 2 }) const minDate = DateTime.now().minus({ years: 2 })
const maxDate = DateTime.now() const maxDate = DateTime.now()

View file

@ -73,7 +73,7 @@ export function DatePickerCalendar({
</button> </button>
<button <button
className="text-white hover:bg-gray-500 flex grow justify-center items-center rounded" className="text-white hover:bg-gray-500 flex grow justify-center items-center rounded"
data-testid="datepicker-range-month-button" data-testid="datepicker-range-year-button"
onClick={() => setView('year')} onClick={() => setView('year')}
> >
{calendars[0].year} {calendars[0].year}

View file

@ -103,7 +103,7 @@ export function DatePickerRangeCalendar({
</button> </button>
<button <button
className="text-white hover:bg-gray-500 flex grow justify-center items-center rounded" className="text-white hover:bg-gray-500 flex grow justify-center items-center rounded"
data-testid="datepicker-range-month-button" data-testid="datepicker-range-year-button"
onClick={() => setView('year')} onClick={() => setView('year')}
> >
{calendars[0].year} {calendars[0].year}

View file

@ -271,7 +271,7 @@ export class InstitutionService implements IInstitutionService {
provider_institution pi provider_institution pi
SET SET
institution_id = i.id, 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 FROM
duplicates d duplicates d
INNER JOIN institutions i ON i.name = d.name AND i.url = d.url INNER JOIN institutions i ON i.name = d.name AND i.url = d.url

View file

@ -1,6 +1,6 @@
import type { AccountConnection, PrismaClient } from '@prisma/client' import type { AccountConnection, PrismaClient } from '@prisma/client'
import type { Logger } from 'winston' 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 type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
import { DbUtil, TellerUtil, type IETL, type ICryptoService } from '@maybe-finance/server/shared' import { DbUtil, TellerUtil, type IETL, type ICryptoService } from '@maybe-finance/server/shared'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
@ -101,16 +101,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
private async _extractAccounts(accessToken: string) { private async _extractAccounts(accessToken: string) {
const accounts = await this.teller.getAccounts({ accessToken }) const accounts = await this.teller.getAccounts({ accessToken })
const accountsWithBalances = await Promise.all( return accounts
accounts.map(async (a) => {
const balance = await this.teller.getAccountBalances({
accountId: a.id,
accessToken,
})
return { ...a, balance }
})
)
return accountsWithBalances
} }
private _loadAccounts(connection: Connection, { accounts }: Pick<TellerData, 'accounts'>) { private _loadAccounts(connection: Connection, { accounts }: Pick<TellerData, 'accounts'>) {
@ -119,6 +110,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
...accounts.map((tellerAccount) => { ...accounts.map((tellerAccount) => {
const type = TellerUtil.getType(tellerAccount.type) const type = TellerUtil.getType(tellerAccount.type)
const classification = AccountUtil.getClassification(type) const classification = AccountUtil.getClassification(type)
return this.prisma.account.upsert({ return this.prisma.account.upsert({
where: { where: {
accountConnectionId_tellerAccountId: { accountConnectionId_tellerAccountId: {
@ -132,6 +124,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type), categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type),
subcategoryProvider: tellerAccount.subtype ?? 'other', subcategoryProvider: tellerAccount.subtype ?? 'other',
accountConnectionId: connection.id, accountConnectionId: connection.id,
userId: connection.userId,
tellerAccountId: tellerAccount.id, tellerAccountId: tellerAccount.id,
name: tellerAccount.name, name: tellerAccount.name,
tellerType: tellerAccount.type, tellerType: tellerAccount.type,
@ -170,27 +163,21 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
private async _extractTransactions(accessToken: string, accountIds: string[]) { private async _extractTransactions(accessToken: string, accountIds: string[]) {
const accountTransactions = await Promise.all( const accountTransactions = await Promise.all(
accountIds.map((accountId) => accountIds.map(async (accountId) => {
SharedUtil.paginate({ const transactions = await SharedUtil.withRetry(
pageSize: 1000, // TODO: Check with Teller on max page size () =>
fetchData: async () => { this.teller.getTransactions({
const transactions = await SharedUtil.withRetry( accountId,
() => accessToken,
this.teller.getTransactions({ }),
accountId, {
accessToken: accessToken, maxRetries: 3,
}), }
{ )
maxRetries: 3,
}
)
return transactions return transactions
}, })
})
)
) )
return accountTransactions.flat() return accountTransactions.flat()
} }
@ -210,7 +197,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
${Prisma.join( ${Prisma.join(
chunk.map((tellerTransaction) => { chunk.map((tellerTransaction) => {
const { const {
id, id: transactionId,
account_id, account_id,
description, description,
amount, amount,
@ -224,15 +211,15 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
(SELECT id FROM account WHERE account_connection_id = ${ (SELECT id FROM account WHERE account_connection_id = ${
connection.id connection.id
} AND teller_account_id = ${account_id.toString()}), } AND teller_account_id = ${account_id.toString()}),
${id}, ${transactionId},
${date}::date, ${date}::date,
${[description].filter(Boolean).join(' ')}, ${description},
${DbUtil.toDecimal(-amount)}, ${DbUtil.toDecimal(Number(amount))},
${status === 'pending'}, ${status === 'pending'},
${'USD'}, ${'USD'},
${details.counterparty.name ?? ''}, ${details.counterparty?.name ?? ''},
${type}, ${type},
${details.category ?? ''}, ${details.category ?? ''}
)` )`
}) })
)} )}

View file

@ -6,19 +6,11 @@ import type {
IAccountConnectionProvider, IAccountConnectionProvider,
} from '../../account-connection' } from '../../account-connection'
import { SharedUtil } from '@maybe-finance/shared' import { SharedUtil } from '@maybe-finance/shared'
import type { SharedType } from '@maybe-finance/shared'
import type { SyncConnectionOptions, CryptoService, IETL } from '@maybe-finance/server/shared' import type { SyncConnectionOptions, CryptoService, IETL } from '@maybe-finance/server/shared'
import _ from 'lodash' import _ from 'lodash'
import { ErrorUtil, etl } from '@maybe-finance/server/shared' import { ErrorUtil, etl } from '@maybe-finance/server/shared'
import type { TellerApi } from '@maybe-finance/teller-api' import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
export interface ITellerConnect {
generateConnectUrl(userId: User['id'], institutionId: string): Promise<{ link: string }>
generateFixConnectUrl(
userId: User['id'],
accountConnectionId: AccountConnection['id']
): Promise<{ link: string }>
}
export class TellerService implements IAccountConnectionProvider, IInstitutionProvider { export class TellerService implements IAccountConnectionProvider, IInstitutionProvider {
constructor( constructor(
@ -44,6 +36,7 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
where: { id: connection.id }, where: { id: connection.id },
data: { data: {
status: 'OK', status: 'OK',
syncStatus: 'IDLE',
}, },
}) })
break break
@ -67,19 +60,28 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
async delete(connection: AccountConnection) { async delete(connection: AccountConnection) {
// purge teller data // purge teller data
if (connection.tellerAccessToken && connection.tellerAccountId) { if (connection.tellerAccessToken && connection.tellerEnrollmentId) {
await this.teller.deleteAccount({ const accounts = await this.prisma.account.findMany({
accessToken: this.crypto.decrypt(connection.tellerAccessToken), where: { accountConnectionId: connection.id },
accountId: connection.tellerAccountId,
}) })
this.logger.info(`Item ${connection.tellerAccountId} removed`) for (const account of accounts) {
if (!account.tellerAccountId) continue
await this.teller.deleteAccount({
accessToken: this.crypto.decrypt(connection.tellerAccessToken),
accountId: account.tellerAccountId,
})
this.logger.info(`Teller account ${account.id} removed`)
}
this.logger.info(`Teller enrollment ${connection.tellerEnrollmentId} removed`)
} }
} }
async getInstitutions() { async getInstitutions() {
const tellerInstitutions = await SharedUtil.paginate({ const tellerInstitutions = await SharedUtil.paginate({
pageSize: 500, pageSize: 10000,
delay: delay:
process.env.NODE_ENV !== 'production' 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 milliseconds: 7_000, // Sandbox rate limited at 10 calls / minute
} }
: undefined, : undefined,
fetchData: (offset, count) => fetchData: () =>
SharedUtil.withRetry( SharedUtil.withRetry(
() => () =>
this.teller.getInstitutions().then((data) => { this.teller.getInstitutions().then((data) => {
this.logger.debug( 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, maxRetries: 3,
onError: (error, attempt) => { onError: (error, attempt) => {
this.logger.error( 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) } { error: ErrorUtil.parseError(error) }
) )
@ -115,12 +117,57 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
return { return {
providerId: id, providerId: id,
name, name,
url: undefined, url: null,
logo: `https://teller.io/images/banks/${id}.jpg}`, logo: null,
primaryColor: undefined, logoUrl: `https://teller.io/images/banks/${id}.jpg`,
oauth: undefined, primaryColor: null,
oauth: false,
data: tellerInstitution, data: tellerInstitution,
} }
}) })
} }
async handleEnrollment(
userId: User['id'],
institution: Pick<TellerTypes.Institution, 'name' | 'id'>,
enrollment: TellerTypes.Enrollment
) {
const connections = await this.prisma.accountConnection.findMany({
where: { userId },
})
if (connections.length > 40) {
throw new Error('MAX_ACCOUNT_CONNECTIONS')
}
const accounts = await this.teller.getAccounts({ accessToken: enrollment.accessToken })
this.logger.info(`Teller accounts retrieved for enrollment ${enrollment.enrollment.id}`)
// If all the accounts are Non-USD, throw an error
if (accounts.every((a) => a.currency !== 'USD')) {
throw new Error('USD_ONLY')
}
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
}
} }

View file

@ -1,4 +1,4 @@
import crypto from 'crypto' import CryptoJS from 'crypto-js'
export interface ICryptoService { export interface ICryptoService {
encrypt(plainText: string): string encrypt(plainText: string): string
@ -6,32 +6,13 @@ export interface ICryptoService {
} }
export class CryptoService implements ICryptoService { export class CryptoService implements ICryptoService {
private key: Buffer constructor(private readonly secret: string) {}
private ivLength = 16 // Initialization vector length. For AES, this is always 16
constructor(private readonly secret: string) {
// Ensure the key length is suitable for AES-256
this.key = crypto.createHash('sha256').update(String(this.secret)).digest()
}
encrypt(plainText: string) { encrypt(plainText: string) {
const iv = crypto.randomBytes(this.ivLength) return CryptoJS.AES.encrypt(plainText, this.secret).toString()
const cipher = crypto.createCipheriv('aes-256-cbc', this.key, iv)
let encrypted = cipher.update(plainText, 'utf8', 'hex')
encrypted += cipher.final('hex')
// Include the IV at the start of the encrypted result
return iv.toString('hex') + ':' + encrypted
} }
decrypt(encrypted: string) { decrypt(encrypted: string) {
const textParts = encrypted.split(':') return CryptoJS.AES.decrypt(encrypted, this.secret).toString(CryptoJS.enc.Utf8)
const iv = Buffer.from(textParts.shift()!, 'hex')
const encryptedText = textParts.join(':')
const decipher = crypto.createDecipheriv('aes-256-cbc', this.key, iv)
let decrypted = decipher.update(encryptedText, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
} }
} }

View file

@ -1,6 +1,6 @@
import type { AccountConnection, User, Account } from '@prisma/client' import type { AccountConnection, User, Account } from '@prisma/client'
import type { Logger } from 'winston' import type { Logger } from 'winston'
import type { Job, JobOptions } from 'bull' import type { Job, JobOptions, JobInformation } from 'bull'
import type { SharedType } from '@maybe-finance/shared' import type { SharedType } from '@maybe-finance/shared'
export type IJob<T> = Pick<Job<T>, 'id' | 'name' | 'data' | 'progress'> export type IJob<T> = Pick<Job<T>, 'id' | 'name' | 'data' | 'progress'>
@ -20,6 +20,8 @@ export type IQueue<TData extends Record<string, any> = {}, TJobName extends stri
options?: { concurrency: number } options?: { concurrency: number }
): Promise<void> ): Promise<void>
getActiveJobs(): Promise<IJob<TData>[]> getActiveJobs(): Promise<IJob<TData>[]>
getRepeatableJobs(): Promise<JobInformation[]>
removeRepeatableByKey(key: string): Promise<void>
cancelJobs(): Promise<void> cancelJobs(): Promise<void>
} }
@ -70,7 +72,7 @@ export type SyncSecurityQueue = IQueue<SyncSecurityQueueJobData, 'sync-all-secur
export type PurgeUserQueue = IQueue<{ userId: User['id'] }, 'purge-user'> export type PurgeUserQueue = IQueue<{ userId: User['id'] }, 'purge-user'>
export type SyncInstitutionQueue = IQueue< export type SyncInstitutionQueue = IQueue<
{}, {},
'sync-finicity-institutions' | 'sync-plaid-institutions' 'sync-finicity-institutions' | 'sync-plaid-institutions' | 'sync-teller-institutions'
> >
export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'> export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>

View file

@ -120,6 +120,14 @@ export class BullQueue<TData extends Record<string, any> = any, TJobName extends
return this.queue.getActive() return this.queue.getActive()
} }
async getRepeatableJobs(): Promise<Queue.JobInformation[]> {
return this.queue.getRepeatableJobs()
}
async removeRepeatableByKey(key: string) {
return this.queue.removeRepeatableByKey(key)
}
async cancelJobs() { async cancelJobs() {
await this.queue.pause(true, true) await this.queue.pause(true, true)
await this.queue.removeJobs('*') await this.queue.removeJobs('*')

View file

@ -53,6 +53,14 @@ export class InMemoryQueue<
return [] return []
} }
async getRepeatableJobs() {
return []
}
async removeRepeatableByKey(_key: string) {
// no-op
}
async cancelJobs() { async cancelJobs() {
// no-op // no-op
} }

View file

@ -2,8 +2,8 @@ import {
Prisma, Prisma,
AccountCategory, AccountCategory,
AccountType, AccountType,
type AccountClassification,
type Account, type Account,
type AccountClassification,
} from '@prisma/client' } from '@prisma/client'
import type { TellerTypes } from '@maybe-finance/teller-api' import type { TellerTypes } from '@maybe-finance/teller-api'
import { Duration } from 'luxon' import { Duration } from 'luxon'
@ -11,10 +11,10 @@ import { Duration } from 'luxon'
/** /**
* Update this with the max window that Teller supports * Update this with the max window that Teller supports
*/ */
export const TELLER_WINDOW_MAX = Duration.fromObject({ years: 1 }) export const TELLER_WINDOW_MAX = Duration.fromObject({ years: 2 })
export function getAccountBalanceData( export function getAccountBalanceData(
{ balances, currency }: Pick<TellerTypes.AccountWithBalances, 'balances' | 'currency'>, { balance, currency }: Pick<TellerTypes.AccountWithBalances, 'balance' | 'currency'>,
classification: AccountClassification classification: AccountClassification
): Pick< ): Pick<
Account, Account,
@ -24,16 +24,11 @@ export function getAccountBalanceData(
| 'availableBalanceStrategy' | 'availableBalanceStrategy'
| 'currencyCode' | 'currencyCode'
> { > {
// Flip balance values to positive for liabilities
const sign = classification === 'liability' ? -1 : 1
return { return {
currentBalanceProvider: new Prisma.Decimal( currentBalanceProvider: new Prisma.Decimal(balance.ledger ? Number(balance.ledger) : 0),
balances.ledger ? sign * Number(balances.ledger) : 0
),
currentBalanceStrategy: 'current', currentBalanceStrategy: 'current',
availableBalanceProvider: new Prisma.Decimal( availableBalanceProvider: new Prisma.Decimal(
balances.available ? sign * Number(balances.available) : 0 balance.available ? Number(balance.available) : 0
), ),
availableBalanceStrategy: 'available', availableBalanceStrategy: 'available',
currencyCode: currency, currencyCode: currency,

View file

@ -34,7 +34,20 @@ export class TellerApi {
*/ */
async getAccounts({ accessToken }: AuthenticatedRequest): Promise<GetAccountsResponse> { async getAccounts({ accessToken }: AuthenticatedRequest): Promise<GetAccountsResponse> {
return this.get<GetAccountsResponse>(`/accounts`, accessToken) const accounts = await this.get<GetAccountsResponse>(`/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<AxiosInstance> { private async getApi(accessToken: string): Promise<AxiosInstance> {
const cert = fs.readFileSync('../../../certs/teller-certificate.pem', 'utf8') const cert = fs.readFileSync('./certs/certificate.pem')
const key = fs.readFileSync('../../../certs/teller-private-key.pem', 'utf8') const key = fs.readFileSync('./certs/private_key.pem')
const agent = new https.Agent({ const agent = new https.Agent({
cert, cert: cert,
key, key: key,
}) })
if (!this.api) { if (!this.api) {
@ -153,16 +166,16 @@ export class TellerApi {
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
}, },
auth: {
username: accessToken,
password: '',
},
}) })
} else if (this.api.defaults.auth?.username !== accessToken) {
this.api.interceptors.request.use((config) => { this.api.defaults.auth = {
// Add the access_token to the auth object username: accessToken,
config.auth = { password: '',
username: 'ACCESS_TOKEN', }
password: accessToken,
}
return config
})
} }
return this.api return this.api

View file

@ -12,13 +12,15 @@ export enum AccountType {
export type DepositorySubtypes = export type DepositorySubtypes =
| 'checking' | 'checking'
| 'savings' | 'savings'
| 'money market' | 'money_market'
| 'certificate of deposit' | 'certificate_of_deposit'
| 'treasury' | 'treasury'
| 'sweep' | 'sweep'
export type CreditSubtype = 'credit_card' export type CreditSubtype = 'credit_card'
export type AccountStatus = 'open' | 'closed'
interface BaseAccount { interface BaseAccount {
enrollment_id: string enrollment_id: string
links: { links: {
@ -34,7 +36,7 @@ interface BaseAccount {
currency: string currency: string
id: string id: string
last_four: string last_four: string
status: 'open' | 'closed' status: AccountStatus
} }
interface DepositoryAccount extends BaseAccount { interface DepositoryAccount extends BaseAccount {
@ -50,10 +52,10 @@ interface CreditAccount extends BaseAccount {
export type Account = DepositoryAccount | CreditAccount export type Account = DepositoryAccount | CreditAccount
export type AccountWithBalances = Account & { export type AccountWithBalances = Account & {
balances: AccountBalance balance: AccountBalance
} }
export type GetAccountsResponse = Account[] export type GetAccountsResponse = AccountWithBalances[]
export type GetAccountResponse = Account export type GetAccountResponse = Account
export type DeleteAccountResponse = void export type DeleteAccountResponse = void

View file

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

View file

@ -3,6 +3,7 @@ export * from './account-balance'
export * from './account-details' export * from './account-details'
export * from './authentication' export * from './authentication'
export * from './error' export * from './error'
export * from './enrollment'
export * from './identity' export * from './identity'
export * from './institutions' export * from './institutions'
export * from './transactions' export * from './transactions'

View file

@ -9,6 +9,4 @@ export type Institution = {
type Capability = 'detail' | 'balance' | 'transaction' | 'identity' type Capability = 'detail' | 'balance' | 'transaction' | 'identity'
export type GetInstitutionsResponse = { export type GetInstitutionsResponse = Institution[]
institutions: Institution[]
}

View file

@ -38,7 +38,7 @@ export type Transaction = {
details: { details: {
category?: DetailCategory category?: DetailCategory
processing_status: DetailProcessingStatus processing_status: DetailProcessingStatus
counterparty: { counterparty?: {
name?: string name?: string
type?: 'organization' | 'person' type?: 'organization' | 'person'
} }

View file

@ -4,8 +4,8 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "nx run-many --target serve --projects=client,server,workers --parallel --host 0.0.0.0 --nx-bail=true --maxParallel=100", "dev": "nx run-many --target serve --projects=client,server,workers --parallel --host 0.0.0.0 --nx-bail=true --maxParallel=100",
"dev:services": "COMPOSE_PROFILES=services docker-compose up -d", "dev:services": "docker compose --profile services up -d",
"dev:services:all": "COMPOSE_PROFILES=services,ngrok docker-compose up -d", "dev:services:all": "docker compose --profile services --profile ngrok up -d",
"dev:workers:test": "nx test workers --skip-nx-cache --runInBand", "dev:workers:test": "nx test workers --skip-nx-cache --runInBand",
"dev:server:test": "nx test server --skip-nx-cache --runInBand", "dev:server:test": "nx test server --skip-nx-cache --runInBand",
"dev:test:unit": "yarn dev:ci:test --testPathPattern='^(?!.*integration).*$' --verbose --skip-nx-cache", "dev:test:unit": "yarn dev:ci:test --testPathPattern='^(?!.*integration).*$' --verbose --skip-nx-cache",
@ -23,7 +23,7 @@
"prisma:migrate:reset": "prisma migrate reset", "prisma:migrate:reset": "prisma migrate reset",
"prisma:seed": "prisma db seed", "prisma:seed": "prisma db seed",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"dev:docker:reset": "docker-compose down -v --rmi all --remove-orphans && docker system prune --all --volumes && docker-compose build", "dev:docker:reset": "docker compose down -v --rmi all --remove-orphans && docker system prune --all --volumes && docker compose build",
"dev:circular": "npx madge --circular --extensions ts libs", "dev:circular": "npx madge --circular --extensions ts libs",
"analyze:client": "ANALYZE=true nx build client --skip-nx-cache", "analyze:client": "ANALYZE=true nx build client --skip-nx-cache",
"tools:pages": "live-server tools/pages", "tools:pages": "live-server tools/pages",
@ -90,6 +90,7 @@
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bull": "^4.10.2", "bull": "^4.10.2",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"cookie": "^0.6.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"cors": "^2.8.5", "cors": "^2.8.5",
@ -148,12 +149,14 @@
"react-popper": "^2.3.0", "react-popper": "^2.3.0",
"react-ranger": "^2.1.0", "react-ranger": "^2.1.0",
"react-responsive": "^9.0.0-beta.10", "react-responsive": "^9.0.0-beta.10",
"react-script-hook": "^1.7.2",
"regenerator-runtime": "0.13.7", "regenerator-runtime": "0.13.7",
"sanitize-html": "^2.8.1", "sanitize-html": "^2.8.1",
"smooth-scroll-into-view-if-needed": "^1.1.33", "smooth-scroll-into-view-if-needed": "^1.1.33",
"stripe": "^10.17.0", "stripe": "^10.17.0",
"superjson": "^1.11.0", "superjson": "^1.11.0",
"tailwindcss": "3.2.4", "tailwindcss": "3.2.4",
"teller-connect-react": "^0.1.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"winston": "^3.8.2", "winston": "^3.8.2",
@ -165,6 +168,7 @@
"@babel/core": "7.17.5", "@babel/core": "7.17.5",
"@babel/preset-react": "^7.14.5", "@babel/preset-react": "^7.14.5",
"@babel/preset-typescript": "7.16.7", "@babel/preset-typescript": "7.16.7",
"@faker-js/faker": "^8.3.1",
"@fast-csv/parse": "^4.3.6", "@fast-csv/parse": "^4.3.6",
"@next/bundle-analyzer": "^13.1.1", "@next/bundle-analyzer": "^13.1.1",
"@nrwl/cli": "15.5.2", "@nrwl/cli": "15.5.2",

View file

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Provider" ADD VALUE 'TELLER';

View file

@ -0,0 +1,9 @@
/*
Warnings:
- You are about to drop the column `teller_account_id` on the `account_connection` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "account_connection" DROP COLUMN "teller_account_id",
ADD COLUMN "teller_enrollment_id" TEXT;

View file

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

View file

@ -71,8 +71,8 @@ model AccountConnection {
finicityError Json? @map("finicity_error") finicityError Json? @map("finicity_error")
// teller data // teller data
tellerAccountId String? @map("teller_account_id")
tellerAccessToken String? @map("teller_access_token") tellerAccessToken String? @map("teller_access_token")
tellerEnrollmentId String? @map("teller_enrollment_id")
tellerInstitutionId String? @map("teller_institution_id") tellerInstitutionId String? @map("teller_institution_id")
tellerError Json? @map("teller_error") tellerError Json? @map("teller_error")
@ -341,7 +341,7 @@ model Transaction {
currencyCode String @default("USD") @map("currency_code") currencyCode String @default("USD") @map("currency_code")
pending Boolean @default(false) pending Boolean @default(false)
merchantName String? @map("merchant_name") 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") categoryUser String? @map("category_user")
excluded Boolean @default(false) excluded Boolean @default(false)
@ -494,6 +494,7 @@ model Institution {
enum Provider { enum Provider {
PLAID PLAID
FINICITY FINICITY
TELLER
} }
model ProviderInstitution { model ProviderInstitution {

View file

@ -1,4 +1,5 @@
import { Institution, PrismaClient, Provider } from '@prisma/client' import { Institution, PrismaClient, Provider } from '@prisma/client'
import bcrypt from 'bcrypt'
const prisma = new PrismaClient() const prisma = new PrismaClient()
@ -27,7 +28,23 @@ async function main() {
}, },
] ]
const hashedPassword = await bcrypt.hash('TestPassword123', 10)
await prisma.$transaction([ await prisma.$transaction([
// create testing auth user
prisma.authUser.upsert({
where: {
id: 'test_ec3ee8a4-fa01-4f11-8ac5-9c49dd7fbae4',
},
create: {
id: 'test_ec3ee8a4-fa01-4f11-8ac5-9c49dd7fbae4',
firstName: 'James',
lastName: 'Bond',
email: 'bond@007.com',
password: hashedPassword,
},
update: {},
}),
// create institution linked to provider institutions // create institution linked to provider institutions
...institutions.map(({ id, name, providers }) => ...institutions.map(({ id, name, providers }) =>
prisma.institution.upsert({ prisma.institution.upsert({

View file

@ -0,0 +1 @@
export * as TellerGenerator from './tellerGenerator'

View file

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

View file

@ -405,7 +405,7 @@
}, },
"test": { "test": {
"executor": "@nrwl/jest:jest", "executor": "@nrwl/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "outputs": ["/coverage/libs/teller-api"],
"options": { "options": {
"jestConfig": "libs/teller-api/jest.config.ts", "jestConfig": "libs/teller-api/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true

View file

@ -1447,6 +1447,11 @@
minimatch "^3.1.2" minimatch "^3.1.2"
strip-json-comments "^3.1.1" 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": "@fast-csv/format@^4.3.5":
version "4.3.5" version "4.3.5"
resolved "https://registry.yarnpkg.com/@fast-csv/format/-/format-4.3.5.tgz#90d83d1b47b6aaf67be70d6118f84f3e12ee1ff3" resolved "https://registry.yarnpkg.com/@fast-csv/format/-/format-4.3.5.tgz#90d83d1b47b6aaf67be70d6118f84f3e12ee1ff3"
@ -7770,7 +7775,7 @@ cookie@0.5.0, cookie@^0.5.0:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
cookie@0.6.0: cookie@0.6.0, cookie@^0.6.0:
version "0.6.0" version "0.6.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
@ -16243,6 +16248,11 @@ react-script-hook@^1.6.0:
resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.7.2.tgz#ec130d67f9a25fcde57fbfd1faa87e5b97521948" resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.7.2.tgz#ec130d67f9a25fcde57fbfd1faa87e5b97521948"
integrity sha512-fhyCEfXb94fag34UPRF0zry1XGwmVY+79iibWwTqAoOiCzYJQOYTiWJ7CnqglA9tMSV8g45cQpHCMcBwr7dwhA== integrity sha512-fhyCEfXb94fag34UPRF0zry1XGwmVY+79iibWwTqAoOiCzYJQOYTiWJ7CnqglA9tMSV8g45cQpHCMcBwr7dwhA==
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: react-shallow-renderer@^16.15.0:
version "16.15.0" version "16.15.0"
resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz#48fb2cf9b23d23cde96708fe5273a7d3446f4457" resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz#48fb2cf9b23d23cde96708fe5273a7d3446f4457"
@ -18150,6 +18160,13 @@ telejson@^6.0.8:
lodash "^4.17.21" lodash "^4.17.21"
memoizerific "^1.11.3" 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: terminal-link@^2.0.0:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994"