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

Merge branch 'main' into main

This commit is contained in:
Sascha Ronnie Daoudia 2024-01-18 19:00:31 +01:00 committed by GitHub
commit c9d3adb5d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 1210 additions and 208 deletions

View file

@ -35,7 +35,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,9 +33,9 @@ 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.
@ -53,6 +49,14 @@ Then, create a new secret using `openssl rand -base64 32` and populate `NEXTAUTH
To enable transactional emails, you'll need to create a [Postmark](https://postmarkapp.com/) account and add your API key to your `.env` file (`NX_POSTMARK_API_TOKEN`). You can also set the from and reply-to email addresses (`NX_POSTMARK_FROM_ADDRESS` and `NX_POSTMARK_REPLY_TO_ADDRESS`). If you want to run the app without email, you can set `NX_POSTMARK_API_TOKEN` to a dummy value. 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:
``` ```

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

@ -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,
@ -156,6 +157,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

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

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,6 +1,8 @@
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> {
try {
// eslint-disable-next-line // eslint-disable-next-line
const [_, __, ___, user] = await prisma.$transaction([ const [_, __, ___, user] = await prisma.$transaction([
prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${authId};`, prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${authId};`,
@ -12,11 +14,15 @@ export async function resetUser(prisma: PrismaClient, authId = 'TODO'): Promise<
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(''),

View file

@ -115,6 +115,11 @@ 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',
{}, {},
@ -131,6 +136,14 @@ syncInstitutionQueue.add(
} }
) )
syncInstitutionQueue.add(
'sync-teller-institutions',
{},
{
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
}
)
/** /**
* send-email queue * send-email queue
*/ */

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

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

@ -49,6 +49,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,15 +163,12 @@ 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({
pageSize: 1000, // TODO: Check with Teller on max page size
fetchData: async () => {
const transactions = await SharedUtil.withRetry( const transactions = await SharedUtil.withRetry(
() => () =>
this.teller.getTransactions({ this.teller.getTransactions({
accountId, accountId,
accessToken: accessToken, accessToken,
}), }),
{ {
maxRetries: 3, maxRetries: 3,
@ -186,11 +176,8 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
) )
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

@ -70,7 +70,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

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

@ -89,6 +89,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",
@ -147,12 +148,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",
@ -163,6 +166,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")
@ -340,7 +340,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)
@ -493,6 +493,7 @@ model Institution {
enum Provider { enum Provider {
PLAID PLAID
FINICITY FINICITY
TELLER
} }
model ProviderInstitution { model ProviderInstitution {

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

@ -1730,6 +1730,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"
@ -7983,7 +7988,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==
@ -16601,6 +16606,11 @@ react-script-hook@^1.6.0:
resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.6.0.tgz#6a44ff5e65113cb29252eadad1b8306f5fe0c626" resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.6.0.tgz#6a44ff5e65113cb29252eadad1b8306f5fe0c626"
integrity sha512-aJm72XGWV+wJTKiqHmAaTNC/JQZV/Drv6A1kd1VQlzhzAXLqtBRBeTt3iTESImGe5TaBDHUOUeaGNw4v+7bqDw== integrity sha512-aJm72XGWV+wJTKiqHmAaTNC/JQZV/Drv6A1kd1VQlzhzAXLqtBRBeTt3iTESImGe5TaBDHUOUeaGNw4v+7bqDw==
react-script-hook@^1.7.2:
version "1.7.2"
resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.7.2.tgz#ec130d67f9a25fcde57fbfd1faa87e5b97521948"
integrity sha512-fhyCEfXb94fag34UPRF0zry1XGwmVY+79iibWwTqAoOiCzYJQOYTiWJ7CnqglA9tMSV8g45cQpHCMcBwr7dwhA==
react-shallow-renderer@^16.15.0: 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"
@ -18526,6 +18536,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"