1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 15:35:22 +02:00

Merge branch 'main' into Dadudidas-patch-1

This commit is contained in:
Sascha Ronnie Daoudia 2024-01-18 19:09:46 +01:00 committed by GitHub
commit 3aba317079
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 1408 additions and 239 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
# account and get a free API key at https://teller.io
NX_TELLER_SIGNING_SECRET=
NX_TELLER_APP_ID=
NEXT_PUBLIC_TELLER_APP_ID=
NEXT_PUBLIC_TELLER_ENV=sandbox
NX_TELLER_ENV=sandbox
########################################################################
@ -57,4 +58,4 @@ NX_POSTMARK_API_TOKEN=
########################################################################
NX_PLAID_SECRET=
NX_FINICITY_APP_KEY=
NX_FINICITY_PARTNER_SECRET=
NX_FINICITY_PARTNER_SECRET=

10
.github/ISSUE_TEMPLATE/custom.md vendored Normal file
View file

@ -0,0 +1,10 @@
---
name: Custom issue template
about: Describe this issue template's purpose here.
title: ''
labels: ''
assignees: ''
---

84
.github/workflows/codeql.yml vendored Normal file
View file

@ -0,0 +1,84 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '20 21 * * 1'
jobs:
analyze:
name: Analyze
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners
# Consider using larger runners for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
permissions:
# required for all workflows
security-events: write
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
language: [ 'javascript-typescript' ]
# CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ]
# Use only 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

79
.github/workflows/snyk-security.yml vendored Normal file
View file

@ -0,0 +1,79 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# A sample workflow which sets up Snyk to analyze the full Snyk platform (Snyk Open Source, Snyk Code,
# Snyk Container and Snyk Infrastructure as Code)
# The setup installs the Snyk CLI - for more details on the possible commands
# check https://docs.snyk.io/snyk-cli/cli-reference
# The results of Snyk Code are then uploaded to GitHub Security Code Scanning
#
# In order to use the Snyk Action you will need to have a Snyk API token.
# More details in https://github.com/snyk/actions#getting-your-snyk-token
# or you can signup for free at https://snyk.io/login
#
# For more examples, including how to limit scans to only high-severity issues
# and fail PR checks, see https://github.com/snyk/actions/
name: Snyk Security
on:
push:
branches: ["main" ]
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
snyk:
permissions:
contents: read # for actions/checkout to fetch code
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Snyk CLI to check for security issues
# Snyk can be used to break the build when it detects security issues.
# In this case we want to upload the SAST issues to GitHub Code Scanning
uses: snyk/actions/setup@806182742461562b67788a64410098c9d9b96adb
# For Snyk Open Source you must first set up the development environment for your application's dependencies
# For example for Node
#- uses: actions/setup-node@v3
# with:
# node-version: 16
env:
# This is where you will need to introduce the Snyk API token created with your Snyk account
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
# Runs Snyk Code (SAST) analysis and uploads result into GitHub.
# Use || true to not fail the pipeline
- name: Snyk Code test
run: snyk code test --sarif > snyk-code.sarif # || true
# Runs Snyk Open Source (SCA) analysis and uploads result to Snyk.
- name: Snyk Open Source monitor
run: snyk monitor --all-projects
# Runs Snyk Infrastructure as Code (IaC) analysis and uploads result to Snyk.
# Use || true to not fail the pipeline.
- name: Snyk IaC test and report
run: snyk iac test --report # || true
# Build the docker image for testing
- name: Build a Docker image
run: docker build -t your/image-to-test .
# Runs Snyk Container (Container and SCA) analysis and uploads result to Snyk.
- name: Snyk Container monitor
run: snyk container monitor your/image-to-test --file=Dockerfile
# Push the Snyk Code results into GitHub Code Scanning tab
- name: Upload result to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: snyk-code.sarif

View file

@ -8,5 +8,7 @@
},
"[html]": {
"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>
🚨 NOTE: This is the original React app of the previously-defunct personal finance app, Maybe. This original version used many external services (Plaid, Finicity, etc) and getting it to fully function will be a decent amount of work.
There's a LOT of work to do to get this functioning, but it should be feasible.
## Backstory
We spent the better part of 2021/2022 building a personal finance + wealth management app called Maybe. Very full-featured, including an "Ask an Advisor" feature which connected users with an actual CFP/CFA to help them with their finances (all included in your subscription).
@ -37,9 +33,9 @@ As a personal finance + wealth management app, Maybe has a lot of features. Here
And dozens upon dozens of smaller features.
## Building the app
## Getting started
This is the current state of building the app. You'll hit errors, which we're working to resolve (and certainly welcome PRs to help with that).
This is the current state of building the app. We're actively working to make this process much more streamlined!
You'll need Docker installed to run the app locally.
@ -53,6 +49,14 @@ Then, create a new secret using `openssl rand -base64 32` and populate `NEXTAUTH
To enable transactional emails, you'll need to create a [Postmark](https://postmarkapp.com/) account and add your API key to your `.env` file (`NX_POSTMARK_API_TOKEN`). You can also set the from and reply-to email addresses (`NX_POSTMARK_FROM_ADDRESS` and `NX_POSTMARK_REPLY_TO_ADDRESS`). If you want to run the app without email, you can set `NX_POSTMARK_API_TOKEN` to a dummy value.
Maybe uses [Teller](https://teller.io/) for connecting financial accounts. To get started with Teller, you'll need to create an account. Once you've created an account:
- Add your Teller application id to your `.env` file (`NEXT_PUBLIC_TELLER_APP_ID`).
- Download your authentication certificates from Teller, create a `certs` folder in the root of the project, and place your certs in that directory. You should have both a `certificate.pem` and `private_key.pem`. **NEVER** check these files into source control, the `.gitignore` file will prevent the `certs/` directory from being added, but please double check.
- Set your `NEXT_PUBLIC_TELLER_ENV` and `NX_TELLER_ENV` to your desired environment. The default is `sandbox` which allows for testing with mock data. The login credentials for the sandbox environment are `username` and `password`. To connect to real financial accounts, you'll need to use the `development` environment.
- Webhooks are not implemented yet, but you can populate the `NX_TELLER_SIGNING_SECRET` with the value from your Teller account.
- We highly recommend checking out the [Teller docs](https://teller.io/docs) for more info.
Then run the following yarn commands:
```

View file

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

View file

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

View file

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

View file

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

View file

@ -36,6 +36,7 @@ import {
valuationsRouter,
institutionsRouter,
finicityRouter,
tellerRouter,
transactionsRouter,
holdingsRouter,
securitiesRouter,
@ -156,6 +157,7 @@ app.use('/v1/users', usersRouter)
app.use('/v1/e2e', e2eRouter)
app.use('/v1/plaid', plaidRouter)
app.use('/v1/finicity', finicityRouter)
app.use('/v1/teller', tellerRouter)
app.use('/v1/accounts', accountsRouter)
app.use('/v1/account-rollup', accountRollupRouter)
app.use('/v1/connections', connectionsRouter)

View file

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

View file

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

View file

@ -5,6 +5,7 @@ export { default as usersRouter } from './users.router'
export { default as webhooksRouter } from './webhooks.router'
export { default as plaidRouter } from './plaid.router'
export { default as finicityRouter } from './finicity.router'
export { default as tellerRouter } from './teller.router'
export { default as valuationsRouter } from './valuations.router'
export { default as institutionsRouter } from './institutions.router'
export { default as transactionsRouter } from './transactions.router'

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_ENV: z.string().optional(),
NX_LD_SDK_KEY: z.string().default('REPLACE_THIS'),
NX_POLYGON_API_KEY: z.string().default(''),
NX_PORT: z.string().default('3333'),

View file

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

View file

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

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({
PLAID: plaidService,
FINICITY: finicityService,
TELLER: tellerService,
})
export const institutionService: IInstitutionService = new InstitutionService(

View file

@ -22,8 +22,6 @@ const envSchema = z.object({
NX_SENTRY_DSN: z.string().optional(),
NX_SENTRY_ENV: z.string().optional(),
NX_LD_SDK_KEY: z.string().default('REPLACE_THIS'),
NX_REDIS_URL: z.string().default('redis://localhost:6379'),
NX_POLYGON_API_KEY: z.string().default(''),

View file

@ -115,6 +115,11 @@ syncInstitutionQueue.process(
async () => await institutionService.sync('FINICITY')
)
syncInstitutionQueue.process(
'sync-teller-institutions',
async () => await institutionService.sync('TELLER')
)
syncInstitutionQueue.add(
'sync-plaid-institutions',
{},
@ -131,6 +136,14 @@ syncInstitutionQueue.add(
}
)
syncInstitutionQueue.add(
'sync-teller-institutions',
{},
{
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
}
)
/**
* send-email queue
*/

View file

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

View file

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

View file

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

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 './useAccountNotifications'
export * from './usePlaid'
export * from './useTeller'
export * from './useProviderStatus'
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: 'add-plaid'; linkToken: string }
| { view: 'add-finicity' }
| { view: 'add-teller' }
| { view: 'add-account' }
| { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> }
| { 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 { DateTime } from 'luxon'
// Set date to Oct 29, 2021 to keep snapshots consistent
beforeAll(() => jest.useFakeTimers().setSystemTime(new Date('2021-10-29 12:00:00')))
// DatePicker configuration
const minDate = DateTime.now().minus({ years: 2 })
const maxDate = DateTime.now()

View file

@ -73,7 +73,7 @@ export function DatePickerCalendar({
</button>
<button
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')}
>
{calendars[0].year}

View file

@ -103,7 +103,7 @@ export function DatePickerRangeCalendar({
</button>
<button
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')}
>
{calendars[0].year}

View file

@ -271,7 +271,7 @@ export class InstitutionService implements IInstitutionService {
provider_institution pi
SET
institution_id = i.id,
rank = (CASE WHEN pi.provider = 'PLAID' THEN 1 ELSE 0 END)
rank = (CASE WHEN pi.provider = 'TELLER' THEN 1 ELSE 0 END)
FROM
duplicates d
INNER JOIN institutions i ON i.name = d.name AND i.url = d.url

View file

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

View file

@ -6,19 +6,11 @@ import type {
IAccountConnectionProvider,
} from '../../account-connection'
import { SharedUtil } from '@maybe-finance/shared'
import type { SharedType } from '@maybe-finance/shared'
import type { SyncConnectionOptions, CryptoService, IETL } from '@maybe-finance/server/shared'
import _ from 'lodash'
import { ErrorUtil, etl } from '@maybe-finance/server/shared'
import type { TellerApi } from '@maybe-finance/teller-api'
export interface ITellerConnect {
generateConnectUrl(userId: User['id'], institutionId: string): Promise<{ link: string }>
generateFixConnectUrl(
userId: User['id'],
accountConnectionId: AccountConnection['id']
): Promise<{ link: string }>
}
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
export class TellerService implements IAccountConnectionProvider, IInstitutionProvider {
constructor(
@ -44,6 +36,7 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
where: { id: connection.id },
data: {
status: 'OK',
syncStatus: 'IDLE',
},
})
break
@ -67,19 +60,28 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
async delete(connection: AccountConnection) {
// purge teller data
if (connection.tellerAccessToken && connection.tellerAccountId) {
await this.teller.deleteAccount({
accessToken: this.crypto.decrypt(connection.tellerAccessToken),
accountId: connection.tellerAccountId,
if (connection.tellerAccessToken && connection.tellerEnrollmentId) {
const accounts = await this.prisma.account.findMany({
where: { accountConnectionId: connection.id },
})
this.logger.info(`Item ${connection.tellerAccountId} removed`)
for (const account of accounts) {
if (!account.tellerAccountId) continue
await this.teller.deleteAccount({
accessToken: this.crypto.decrypt(connection.tellerAccessToken),
accountId: account.tellerAccountId,
})
this.logger.info(`Teller account ${account.id} removed`)
}
this.logger.info(`Teller enrollment ${connection.tellerEnrollmentId} removed`)
}
}
async getInstitutions() {
const tellerInstitutions = await SharedUtil.paginate({
pageSize: 500,
pageSize: 10000,
delay:
process.env.NODE_ENV !== 'production'
? {
@ -87,20 +89,20 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
milliseconds: 7_000, // Sandbox rate limited at 10 calls / minute
}
: undefined,
fetchData: (offset, count) =>
fetchData: () =>
SharedUtil.withRetry(
() =>
this.teller.getInstitutions().then((data) => {
this.logger.debug(
`paginated teller fetch inst=${data.institutions.length} (total=${data.institutions.length} offset=${offset} count=${count})`
`teller fetch inst=${data.length} (total=${data.length})`
)
return data.institutions
return data
}),
{
maxRetries: 3,
onError: (error, attempt) => {
this.logger.error(
`Teller fetch institutions request failed attempt=${attempt} offset=${offset} count=${count}`,
`Teller fetch institutions request failed attempt=${attempt}`,
{ error: ErrorUtil.parseError(error) }
)
@ -115,12 +117,57 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
return {
providerId: id,
name,
url: undefined,
logo: `https://teller.io/images/banks/${id}.jpg}`,
primaryColor: undefined,
oauth: undefined,
url: null,
logo: null,
logoUrl: `https://teller.io/images/banks/${id}.jpg`,
primaryColor: null,
oauth: false,
data: tellerInstitution,
}
})
}
async handleEnrollment(
userId: User['id'],
institution: Pick<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 {
encrypt(plainText: string): string
@ -6,32 +6,13 @@ export interface ICryptoService {
}
export class CryptoService implements ICryptoService {
private key: Buffer
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()
}
constructor(private readonly secret: string) {}
encrypt(plainText: string) {
const iv = crypto.randomBytes(this.ivLength)
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
return CryptoJS.AES.encrypt(plainText, this.secret).toString()
}
decrypt(encrypted: string) {
const textParts = encrypted.split(':')
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
return CryptoJS.AES.decrypt(encrypted, this.secret).toString(CryptoJS.enc.Utf8)
}
}

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 SyncInstitutionQueue = IQueue<
{},
'sync-finicity-institutions' | 'sync-plaid-institutions'
'sync-finicity-institutions' | 'sync-plaid-institutions' | 'sync-teller-institutions'
>
export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>

View file

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

View file

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

View file

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

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 './authentication'
export * from './error'
export * from './enrollment'
export * from './identity'
export * from './institutions'
export * from './transactions'

View file

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

View file

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

View file

@ -89,6 +89,7 @@
"bcrypt": "^5.1.1",
"bull": "^4.10.2",
"classnames": "^2.3.1",
"cookie": "^0.6.0",
"cookie-parser": "^1.4.6",
"core-js": "^3.6.5",
"cors": "^2.8.5",
@ -107,7 +108,7 @@
"http-errors": "^2.0.0",
"ioredis": "^5.2.4",
"is-ci": "^3.0.1",
"jsonwebtoken": "^8.5.1",
"jsonwebtoken": "^9.0.0",
"jwk-to-pem": "^2.0.5",
"jwks-rsa": "^3.0.0",
"jwt-decode": "^3.1.2",
@ -147,12 +148,14 @@
"react-popper": "^2.3.0",
"react-ranger": "^2.1.0",
"react-responsive": "^9.0.0-beta.10",
"react-script-hook": "^1.7.2",
"regenerator-runtime": "0.13.7",
"sanitize-html": "^2.8.1",
"smooth-scroll-into-view-if-needed": "^1.1.33",
"stripe": "^10.17.0",
"superjson": "^1.11.0",
"tailwindcss": "3.2.4",
"teller-connect-react": "^0.1.0",
"tslib": "^2.3.0",
"uuid": "^9.0.0",
"winston": "^3.8.2",
@ -163,6 +166,7 @@
"@babel/core": "7.17.5",
"@babel/preset-react": "^7.14.5",
"@babel/preset-typescript": "7.16.7",
"@faker-js/faker": "^8.3.1",
"@fast-csv/parse": "^4.3.6",
"@next/bundle-analyzer": "^13.1.1",
"@nrwl/cli": "15.5.2",

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")
// teller data
tellerAccountId String? @map("teller_account_id")
tellerAccessToken String? @map("teller_access_token")
tellerEnrollmentId String? @map("teller_enrollment_id")
tellerInstitutionId String? @map("teller_institution_id")
tellerError Json? @map("teller_error")
@ -340,7 +340,7 @@ model Transaction {
currencyCode String @default("USD") @map("currency_code")
pending Boolean @default(false)
merchantName String? @map("merchant_name")
category String @default(dbgenerated("COALESCE(category_user,\nCASE\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'INCOME'::text) THEN 'Income'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = ANY (ARRAY['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text])) THEN 'Housing Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = 'LOAN_PAYMENTS_CAR_PAYMENT'::text) THEN 'Vehicle Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'LOAN_PAYMENTS'::text) THEN 'Other Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'HOME_IMPROVEMENT'::text) THEN 'Home Improvement'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'GENERAL_MERCHANDISE'::text) THEN 'Shopping'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = 'RENT_AND_UTILITIES'::text) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'RENT_AND_UTILITIES_RENT'::text)) THEN 'Utilities'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'FOOD_AND_DRINK'::text) THEN 'Food and Drink'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRANSPORTATION'::text) THEN 'Transportation'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRAVEL'::text) THEN 'Travel'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = ANY (ARRAY['PERSONAL_CARE'::text, 'MEDICAL'::text])) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'MEDICAL_VETERINARY_SERVICES'::text)) THEN 'Health'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Income'::text, 'Paycheck'::text])) THEN 'Income'::text\n WHEN ((finicity_categorization ->> 'category'::text) = 'Mortgage & Rent'::text) THEN 'Housing Payments'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Furnishings'::text, 'Home Services'::text, 'Home Improvement'::text, 'Lawn and Garden'::text])) THEN 'Home Improvement'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Streaming Services'::text, 'Home Phone'::text, 'Television'::text, 'Bills & Utilities'::text, 'Utilities'::text, 'Internet / Broadband Charges'::text, 'Mobile Phone'::text])) THEN 'Utilities'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Fast Food'::text, 'Food & Dining'::text, 'Restaurants'::text, 'Coffee Shops'::text, 'Alcohol & Bars'::text, 'Groceries'::text])) THEN 'Food and Drink'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Auto & Transport'::text, 'Gas & Fuel'::text, 'Auto Insurance'::text])) THEN 'Transportation'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Hotel'::text, 'Travel'::text, 'Rental Car & Taxi'::text])) THEN 'Travel'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Health Insurance'::text, 'Doctor'::text, 'Pharmacy'::text, 'Eyecare'::text, 'Health & Fitness'::text, 'Personal Care'::text])) THEN 'Health'::text\n ELSE 'Other'::text\nEND)"))
category String @default(dbgenerated("COALESCE(category_user,\nCASE\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'INCOME'::text) THEN 'Income'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = ANY (ARRAY['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text])) THEN 'Housing Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = 'LOAN_PAYMENTS_CAR_PAYMENT'::text) THEN 'Vehicle Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'LOAN_PAYMENTS'::text) THEN 'Other Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'HOME_IMPROVEMENT'::text) THEN 'Home Improvement'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'GENERAL_MERCHANDISE'::text) THEN 'Shopping'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = 'RENT_AND_UTILITIES'::text) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'RENT_AND_UTILITIES_RENT'::text)) THEN 'Utilities'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'FOOD_AND_DRINK'::text) THEN 'Food and Drink'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRANSPORTATION'::text) THEN 'Transportation'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRAVEL'::text) THEN 'Travel'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = ANY (ARRAY['PERSONAL_CARE'::text, 'MEDICAL'::text])) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'MEDICAL_VETERINARY_SERVICES'::text)) THEN 'Health'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Income'::text, 'Paycheck'::text])) THEN 'Income'::text\n WHEN ((finicity_categorization ->> 'category'::text) = 'Mortgage & Rent'::text) THEN 'Housing Payments'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Furnishings'::text, 'Home Services'::text, 'Home Improvement'::text, 'Lawn and Garden'::text])) THEN 'Home Improvement'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Streaming Services'::text, 'Home Phone'::text, 'Television'::text, 'Bills & Utilities'::text, 'Utilities'::text, 'Internet / Broadband Charges'::text, 'Mobile Phone'::text])) THEN 'Utilities'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Fast Food'::text, 'Food & Dining'::text, 'Restaurants'::text, 'Coffee Shops'::text, 'Alcohol & Bars'::text, 'Groceries'::text])) THEN 'Food and Drink'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Auto & Transport'::text, 'Gas & Fuel'::text, 'Auto Insurance'::text])) THEN 'Transportation'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Hotel'::text, 'Travel'::text, 'Rental Car & Taxi'::text])) THEN 'Travel'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Health Insurance'::text, 'Doctor'::text, 'Pharmacy'::text, 'Eyecare'::text, 'Health & Fitness'::text, 'Personal Care'::text])) THEN 'Health'::text\n WHEN (teller_category = 'income'::text) THEN 'Income'::text\n WHEN (teller_category = 'home'::text) THEN 'Home Improvement'::text\n WHEN (teller_category = ANY (ARRAY['phone'::text, 'utilities'::text])) THEN 'Utilities'::text\n WHEN (teller_category = ANY (ARRAY['dining'::text, 'bar'::text, 'groceries'::text])) THEN 'Food and Drink'::text\n WHEN (teller_category = ANY (ARRAY['clothing'::text, 'entertainment'::text, 'shopping'::text, 'electronics'::text, 'software'::text, 'sport'::text])) THEN 'Shopping'::text\n WHEN (teller_category = ANY (ARRAY['transportation'::text, 'fuel'::text])) THEN 'Transportation'::text\n WHEN (teller_category = ANY (ARRAY['accommodation'::text, 'transport'::text])) THEN 'Travel'::text\n WHEN (teller_category = 'health'::text) THEN 'Health'::text\n WHEN (teller_category = ANY (ARRAY['loan'::text, 'tax'::text, 'insurance'::text, 'office'::text])) THEN 'Other Payments'::text\n ELSE 'Other'::text\nEND)"))
categoryUser String? @map("category_user")
excluded Boolean @default(false)
@ -493,6 +493,7 @@ model Institution {
enum Provider {
PLAID
FINICITY
TELLER
}
model ProviderInstitution {

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": {
"executor": "@nrwl/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"outputs": ["/coverage/libs/teller-api"],
"options": {
"jestConfig": "libs/teller-api/jest.config.ts",
"passWithNoTests": true

View file

@ -1730,6 +1730,11 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@faker-js/faker@^8.3.1":
version "8.3.1"
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-8.3.1.tgz#7753df0cb88d7649becf984a96dd1bd0a26f43e3"
integrity sha512-FdgpFxY6V6rLZE9mmIBb9hM0xpfvQOSNOLnzolzKwsE1DH+gC7lEKV1p1IbR0lAYyvYd5a4u3qWJzowUkw1bIw==
"@fast-csv/format@^4.3.5":
version "4.3.5"
resolved "https://registry.yarnpkg.com/@fast-csv/format/-/format-4.3.5.tgz#90d83d1b47b6aaf67be70d6118f84f3e12ee1ff3"
@ -7983,7 +7988,7 @@ cookie@0.5.0, cookie@^0.5.0:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
cookie@0.6.0:
cookie@0.6.0, cookie@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
@ -8670,9 +8675,9 @@ decimal.js@^10.3.1, decimal.js@^10.4.2:
integrity sha512-ic1yEvwT6GuvaYwBLLY6/aFFgjZdySKTE8en/fkU3QICTmRtgtSlFn0u0BXN06InZwtfCelR7j8LRiDI/02iGA==
decode-uri-component@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
integrity sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==
version "0.2.2"
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9"
integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==
decompress-response@^6.0.0:
version "6.0.0"
@ -13102,6 +13107,16 @@ jsonwebtoken@^8.5.1:
ms "^2.1.1"
semver "^5.6.0"
jsonwebtoken@^9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz#d0faf9ba1cc3a56255fe49c0961a67e520c1926d"
integrity sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==
dependencies:
jws "^3.2.2"
lodash "^4.17.21"
ms "^2.1.1"
semver "^7.3.8"
jsprim@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-2.0.2.tgz#77ca23dbcd4135cd364800d22ff82c2185803d4d"
@ -13414,24 +13429,15 @@ loader-runner@^4.2.0:
integrity sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==
loader-utils@^1.2.3, loader-utils@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613"
integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==
version "1.4.2"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.2.tgz#29a957f3a63973883eb684f10ffd3d151fec01a3"
integrity sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==
dependencies:
big.js "^5.2.2"
emojis-list "^3.0.0"
json5 "^1.0.1"
loader-utils@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.2.tgz#d6e3b4fb81870721ae4e0868ab11dd638368c129"
integrity sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==
dependencies:
big.js "^5.2.2"
emojis-list "^3.0.0"
json5 "^2.1.2"
loader-utils@^2.0.3, loader-utils@^2.0.4:
loader-utils@^2.0.0, loader-utils@^2.0.3, loader-utils@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c"
integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==
@ -13441,9 +13447,9 @@ loader-utils@^2.0.3, loader-utils@^2.0.4:
json5 "^2.1.2"
loader-utils@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-3.2.0.tgz#bcecc51a7898bee7473d4bc6b845b23af8304d4f"
integrity sha512-HVl9ZqccQihZ7JM85dco1MvO9G+ONvxoGa9rkhzFsneGLKSUg1gJf9bWzhRhcvm2qChhWpebQhP44qxjKIUCaQ==
version "3.2.1"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-3.2.1.tgz#4fb104b599daafd82ef3e1a41fb9265f87e1f576"
integrity sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==
locate-path@^2.0.0:
version "2.0.0"
@ -16600,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"
integrity sha512-aJm72XGWV+wJTKiqHmAaTNC/JQZV/Drv6A1kd1VQlzhzAXLqtBRBeTt3iTESImGe5TaBDHUOUeaGNw4v+7bqDw==
react-script-hook@^1.7.2:
version "1.7.2"
resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.7.2.tgz#ec130d67f9a25fcde57fbfd1faa87e5b97521948"
integrity sha512-fhyCEfXb94fag34UPRF0zry1XGwmVY+79iibWwTqAoOiCzYJQOYTiWJ7CnqglA9tMSV8g45cQpHCMcBwr7dwhA==
react-shallow-renderer@^16.15.0:
version "16.15.0"
resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz#48fb2cf9b23d23cde96708fe5273a7d3446f4457"
@ -17431,10 +17442,10 @@ semver@7.3.4:
dependencies:
lru-cache "^6.0.0"
semver@7.x:
version "7.3.5"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
semver@7.x, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8:
version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
dependencies:
lru-cache "^6.0.0"
@ -17443,13 +17454,6 @@ semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7:
version "7.3.7"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
dependencies:
lru-cache "^6.0.0"
send@0.17.2, send@latest:
version "0.17.2"
resolved "https://registry.yarnpkg.com/send/-/send-0.17.2.tgz#926622f76601c41808012c8bf1688fe3906f7820"
@ -18532,6 +18536,13 @@ telejson@^6.0.8:
lodash "^4.17.21"
memoizerific "^1.11.3"
teller-connect-react@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/teller-connect-react/-/teller-connect-react-0.1.0.tgz#b3bae24f4410d622eb8c88c7668adb003eb7bfd7"
integrity sha512-ZI+OULCsuo/v1qetpjepOgM7TyIzwnMVE/54IruOPguQtJ/Ui3C1ax3wUb65AKZDyVQ7ZyjA+8ypT/yMYD9bIQ==
dependencies:
react-script-hook "^1.7.2"
terminal-link@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994"