mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
Merge branch 'main' into Dadudidas-patch-1
This commit is contained in:
commit
3aba317079
53 changed files with 1408 additions and 239 deletions
|
@ -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
10
.github/ISSUE_TEMPLATE/custom.md
vendored
Normal 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
84
.github/workflows/codeql.yml
vendored
Normal 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
79
.github/workflows/snyk-security.yml
vendored
Normal 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
|
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
@ -8,5 +8,7 @@
|
|||
},
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
},
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
|
16
README.md
16
README.md
|
@ -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:
|
||||
|
||||
```
|
||||
|
|
|
@ -85,7 +85,6 @@ export const authOptions = {
|
|||
strategy: 'jwt' as SessionStrategy,
|
||||
maxAge: 1 * 24 * 60 * 60, // 1 Day
|
||||
},
|
||||
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: 'Credentials',
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>) => {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -240,6 +240,7 @@ const userService = new UserService(
|
|||
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||
PLAID: plaidService,
|
||||
FINICITY: finicityService,
|
||||
TELLER: tellerService,
|
||||
})
|
||||
|
||||
const institutionService: IInstitutionService = new InstitutionService(
|
||||
|
|
|
@ -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' })
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
45
apps/server/src/app/routes/teller.router.ts
Normal file
45
apps/server/src/app/routes/teller.router.ts
Normal 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
|
|
@ -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'),
|
||||
|
|
|
@ -68,8 +68,8 @@ describe('Finicity', () => {
|
|||
userId: user.id,
|
||||
name: 'TEST_FINICITY',
|
||||
type: 'finicity',
|
||||
finicityInstitutionId: 'REPLACE_THIS',
|
||||
finicityInstitutionLoginId: 'REPLACE_THIS',
|
||||
finicityInstitutionId: '101732',
|
||||
finicityInstitutionLoginId: '6000483842',
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
118
apps/workers/src/app/__tests__/teller.integration.spec.ts
Normal file
118
apps/workers/src/app/__tests__/teller.integration.spec.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
|
@ -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(
|
||||
|
|
|
@ -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(''),
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
|
@ -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'
|
||||
|
|
64
libs/client/shared/src/api/useTellerApi.ts
Normal file
64
libs/client/shared/src/api/useTellerApi.ts
Normal 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,
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
|
|
182
libs/client/shared/src/hooks/useTeller.ts
Normal file
182
libs/client/shared/src/hooks/useTeller.ts
Normal 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,
|
||||
}
|
||||
}
|
|
@ -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> }
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ?? ''}
|
||||
)`
|
||||
})
|
||||
)}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'>
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
13
libs/teller-api/src/types/enrollment.ts
Normal file
13
libs/teller-api/src/types/enrollment.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
export type Enrollment = {
|
||||
accessToken: string
|
||||
user: {
|
||||
id: string
|
||||
}
|
||||
enrollment: {
|
||||
id: string
|
||||
institution: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
signatures?: string[]
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -9,6 +9,4 @@ export type Institution = {
|
|||
|
||||
type Capability = 'detail' | 'balance' | 'transaction' | 'identity'
|
||||
|
||||
export type GetInstitutionsResponse = {
|
||||
institutions: Institution[]
|
||||
}
|
||||
export type GetInstitutionsResponse = Institution[]
|
||||
|
|
|
@ -38,7 +38,7 @@ export type Transaction = {
|
|||
details: {
|
||||
category?: DetailCategory
|
||||
processing_status: DetailProcessingStatus
|
||||
counterparty: {
|
||||
counterparty?: {
|
||||
name?: string
|
||||
type?: 'organization' | 'person'
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "Provider" ADD VALUE 'TELLER';
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 {
|
||||
|
|
1
tools/generators/index.ts
Normal file
1
tools/generators/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * as TellerGenerator from './tellerGenerator'
|
248
tools/generators/tellerGenerator.ts
Normal file
248
tools/generators/tellerGenerator.ts
Normal 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,
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
73
yarn.lock
73
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue