mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 15:35:22 +02:00
Merge branch 'main' into Dadudidas-patch-1
This commit is contained in:
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
|
# We use Teller.io for automated banking data. You can sign up for a free
|
||||||
# account and get a free API key at https://teller.io
|
# account and get a free API key at https://teller.io
|
||||||
NX_TELLER_SIGNING_SECRET=
|
NX_TELLER_SIGNING_SECRET=
|
||||||
NX_TELLER_APP_ID=
|
NEXT_PUBLIC_TELLER_APP_ID=
|
||||||
|
NEXT_PUBLIC_TELLER_ENV=sandbox
|
||||||
NX_TELLER_ENV=sandbox
|
NX_TELLER_ENV=sandbox
|
||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
@ -57,4 +58,4 @@ NX_POSTMARK_API_TOKEN=
|
||||||
########################################################################
|
########################################################################
|
||||||
NX_PLAID_SECRET=
|
NX_PLAID_SECRET=
|
||||||
NX_FINICITY_APP_KEY=
|
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]": {
|
"[html]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"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>
|
<b>Get involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybe.co) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
|
||||||
|
|
||||||
🚨 NOTE: This is the original React app of the previously-defunct personal finance app, Maybe. This original version used many external services (Plaid, Finicity, etc) and getting it to fully function will be a decent amount of work.
|
|
||||||
|
|
||||||
There's a LOT of work to do to get this functioning, but it should be feasible.
|
|
||||||
|
|
||||||
## Backstory
|
## Backstory
|
||||||
|
|
||||||
We spent the better part of 2021/2022 building a personal finance + wealth management app called Maybe. Very full-featured, including an "Ask an Advisor" feature which connected users with an actual CFP/CFA to help them with their finances (all included in your subscription).
|
We spent the better part of 2021/2022 building a personal finance + wealth management app called Maybe. Very full-featured, including an "Ask an Advisor" feature which connected users with an actual CFP/CFA to help them with their finances (all included in your subscription).
|
||||||
|
@ -37,9 +33,9 @@ As a personal finance + wealth management app, Maybe has a lot of features. Here
|
||||||
|
|
||||||
And dozens upon dozens of smaller features.
|
And dozens upon dozens of smaller features.
|
||||||
|
|
||||||
## Building the app
|
## Getting started
|
||||||
|
|
||||||
This is the current state of building the app. You'll hit errors, which we're working to resolve (and certainly welcome PRs to help with that).
|
This is the current state of building the app. We're actively working to make this process much more streamlined!
|
||||||
|
|
||||||
You'll need Docker installed to run the app locally.
|
You'll need Docker installed to run the app locally.
|
||||||
|
|
||||||
|
@ -53,6 +49,14 @@ Then, create a new secret using `openssl rand -base64 32` and populate `NEXTAUTH
|
||||||
|
|
||||||
To enable transactional emails, you'll need to create a [Postmark](https://postmarkapp.com/) account and add your API key to your `.env` file (`NX_POSTMARK_API_TOKEN`). You can also set the from and reply-to email addresses (`NX_POSTMARK_FROM_ADDRESS` and `NX_POSTMARK_REPLY_TO_ADDRESS`). If you want to run the app without email, you can set `NX_POSTMARK_API_TOKEN` to a dummy value.
|
To enable transactional emails, you'll need to create a [Postmark](https://postmarkapp.com/) account and add your API key to your `.env` file (`NX_POSTMARK_API_TOKEN`). You can also set the from and reply-to email addresses (`NX_POSTMARK_FROM_ADDRESS` and `NX_POSTMARK_REPLY_TO_ADDRESS`). If you want to run the app without email, you can set `NX_POSTMARK_API_TOKEN` to a dummy value.
|
||||||
|
|
||||||
|
Maybe uses [Teller](https://teller.io/) for connecting financial accounts. To get started with Teller, you'll need to create an account. Once you've created an account:
|
||||||
|
|
||||||
|
- Add your Teller application id to your `.env` file (`NEXT_PUBLIC_TELLER_APP_ID`).
|
||||||
|
- Download your authentication certificates from Teller, create a `certs` folder in the root of the project, and place your certs in that directory. You should have both a `certificate.pem` and `private_key.pem`. **NEVER** check these files into source control, the `.gitignore` file will prevent the `certs/` directory from being added, but please double check.
|
||||||
|
- Set your `NEXT_PUBLIC_TELLER_ENV` and `NX_TELLER_ENV` to your desired environment. The default is `sandbox` which allows for testing with mock data. The login credentials for the sandbox environment are `username` and `password`. To connect to real financial accounts, you'll need to use the `development` environment.
|
||||||
|
- Webhooks are not implemented yet, but you can populate the `NX_TELLER_SIGNING_SECRET` with the value from your Teller account.
|
||||||
|
- We highly recommend checking out the [Teller docs](https://teller.io/docs) for more info.
|
||||||
|
|
||||||
Then run the following yarn commands:
|
Then run the following yarn commands:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
@ -85,7 +85,6 @@ export const authOptions = {
|
||||||
strategy: 'jwt' as SessionStrategy,
|
strategy: 'jwt' as SessionStrategy,
|
||||||
maxAge: 1 * 24 * 60 * 60, // 1 Day
|
maxAge: 1 * 24 * 60 * 60, // 1 Day
|
||||||
},
|
},
|
||||||
|
|
||||||
providers: [
|
providers: [
|
||||||
CredentialsProvider({
|
CredentialsProvider({
|
||||||
name: 'Credentials',
|
name: 'Credentials',
|
||||||
|
|
|
@ -9,7 +9,6 @@ import {
|
||||||
} from '@maybe-finance/server/features'
|
} from '@maybe-finance/server/features'
|
||||||
import { InMemoryQueueFactory, PgService, type IQueueFactory } from '@maybe-finance/server/shared'
|
import { InMemoryQueueFactory, PgService, type IQueueFactory } from '@maybe-finance/server/shared'
|
||||||
import { createLogger, transports } from 'winston'
|
import { createLogger, transports } from 'winston'
|
||||||
import isCI from 'is-ci'
|
|
||||||
import nock from 'nock'
|
import nock from 'nock'
|
||||||
import Decimal from 'decimal.js'
|
import Decimal from 'decimal.js'
|
||||||
import { startServer, stopServer } from './utils/server'
|
import { startServer, stopServer } from './utils/server'
|
||||||
|
@ -27,7 +26,7 @@ const prisma = new PrismaClient()
|
||||||
// For TypeScript support
|
// For TypeScript support
|
||||||
const plaid = jest.mocked(_plaid) // eslint-disable-line
|
const plaid = jest.mocked(_plaid) // eslint-disable-line
|
||||||
|
|
||||||
const auth0Id = isCI ? 'auth0|61afd38f678a0c006895f046' : 'auth0|61afd340678a0c006895f000'
|
const authId = '__TEST_USER_ID__'
|
||||||
let axios: AxiosInstance
|
let axios: AxiosInstance
|
||||||
let user: User
|
let user: User
|
||||||
|
|
||||||
|
@ -38,7 +37,7 @@ if (process.env.IS_VSCODE_DEBUG === 'true') {
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Clears old user and data, creates new user
|
// Clears old user and data, creates new user
|
||||||
user = await resetUser(auth0Id)
|
user = await resetUser(authId)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('/v1/accounts API', () => {
|
describe('/v1/accounts API', () => {
|
||||||
|
|
|
@ -2,7 +2,6 @@ import type { AxiosInstance } from 'axios'
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
import type { Prisma, AccountConnection, AccountSyncStatus, User } from '@prisma/client'
|
import type { Prisma, AccountConnection, AccountSyncStatus, User } from '@prisma/client'
|
||||||
import type { ItemRemoveResponse } from 'plaid'
|
import type { ItemRemoveResponse } from 'plaid'
|
||||||
import isCI from 'is-ci'
|
|
||||||
import { startServer, stopServer } from './utils/server'
|
import { startServer, stopServer } from './utils/server'
|
||||||
import { getAxiosClient } from './utils/axios'
|
import { getAxiosClient } from './utils/axios'
|
||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
|
@ -18,7 +17,7 @@ jest.mock('plaid')
|
||||||
// For TypeScript support
|
// For TypeScript support
|
||||||
const plaid = jest.mocked(_plaid)
|
const plaid = jest.mocked(_plaid)
|
||||||
|
|
||||||
const auth0Id = isCI ? 'auth0|61afd38f678a0c006895f046' : 'auth0|61afd340678a0c006895f000'
|
const authId = '__TEST_USER_ID__'
|
||||||
let axios: AxiosInstance
|
let axios: AxiosInstance
|
||||||
let user: User | null
|
let user: User | null
|
||||||
let connection: AccountConnection
|
let connection: AccountConnection
|
||||||
|
@ -45,7 +44,7 @@ afterAll(async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
user = await resetUser(auth0Id)
|
user = await resetUser(authId)
|
||||||
|
|
||||||
connectionData = {
|
connectionData = {
|
||||||
data: {
|
data: {
|
||||||
|
|
|
@ -1,38 +1,37 @@
|
||||||
import type { AxiosResponse } from 'axios'
|
import type { AxiosResponse } from 'axios'
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
import { superjson } from '@maybe-finance/shared'
|
import { superjson } from '@maybe-finance/shared'
|
||||||
import env from '../../../env'
|
|
||||||
import isCI from 'is-ci'
|
|
||||||
import Axios from 'axios'
|
import Axios from 'axios'
|
||||||
|
import { encode } from 'next-auth/jwt'
|
||||||
|
|
||||||
// Fetches Auth0 access token (JWT) and prepares Axios client to use it on each request
|
|
||||||
export async function getAxiosClient() {
|
export async function getAxiosClient() {
|
||||||
const tenantUrl = isCI
|
const baseUrl = 'http://127.0.0.1:53333/v1'
|
||||||
? 'REPLACE_THIS-staging.us.auth0.com'
|
const jwt = await encode({
|
||||||
: 'REPLACE_THIS-development.us.auth0.com'
|
maxAge: 1 * 24 * 60 * 60,
|
||||||
|
secret: process.env.NEXTAUTH_SECRET || 'CHANGE_ME',
|
||||||
const {
|
token: {
|
||||||
data: { access_token: token },
|
sub: '__TEST_USER_ID__',
|
||||||
} = await Axios.request({
|
user: '__TEST_USER_ID__',
|
||||||
method: 'POST',
|
'https://maybe.co/email': 'REPLACE_THIS',
|
||||||
url: `https://${tenantUrl}/oauth/token`,
|
firstName: 'REPLACE_THIS',
|
||||||
headers: { 'content-type': 'application/json' },
|
lastName: 'REPLACE_THIS',
|
||||||
data: {
|
name: 'REPLACE_THIS',
|
||||||
grant_type: 'password',
|
|
||||||
username: 'REPLACE_THIS',
|
|
||||||
password: 'REPLACE_THIS',
|
|
||||||
audience: 'https://maybe-finance-api/v1',
|
|
||||||
scope: '',
|
|
||||||
client_id: isCI ? 'REPLACE_THIS' : 'REPLACE_THIS',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const defaultHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Access-Control-Allow-Credentials': true,
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
}
|
||||||
|
const axiosOptions = {
|
||||||
|
baseURL: baseUrl,
|
||||||
|
headers: defaultHeaders,
|
||||||
|
}
|
||||||
|
|
||||||
const axios = Axios.create({
|
const axios = Axios.create({
|
||||||
baseURL: 'http://127.0.0.1:53333/v1',
|
...axiosOptions,
|
||||||
validateStatus: () => true, // Tests should determine whether status is correct, not Axios
|
validateStatus: () => true, // Tests should determine whether status is correct, not Axios
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
axios.interceptors.response.use((response: AxiosResponse<SharedType.BaseResponse>) => {
|
axios.interceptors.response.use((response: AxiosResponse<SharedType.BaseResponse>) => {
|
||||||
|
|
|
@ -36,6 +36,7 @@ import {
|
||||||
valuationsRouter,
|
valuationsRouter,
|
||||||
institutionsRouter,
|
institutionsRouter,
|
||||||
finicityRouter,
|
finicityRouter,
|
||||||
|
tellerRouter,
|
||||||
transactionsRouter,
|
transactionsRouter,
|
||||||
holdingsRouter,
|
holdingsRouter,
|
||||||
securitiesRouter,
|
securitiesRouter,
|
||||||
|
@ -156,6 +157,7 @@ app.use('/v1/users', usersRouter)
|
||||||
app.use('/v1/e2e', e2eRouter)
|
app.use('/v1/e2e', e2eRouter)
|
||||||
app.use('/v1/plaid', plaidRouter)
|
app.use('/v1/plaid', plaidRouter)
|
||||||
app.use('/v1/finicity', finicityRouter)
|
app.use('/v1/finicity', finicityRouter)
|
||||||
|
app.use('/v1/teller', tellerRouter)
|
||||||
app.use('/v1/accounts', accountsRouter)
|
app.use('/v1/accounts', accountsRouter)
|
||||||
app.use('/v1/account-rollup', accountRollupRouter)
|
app.use('/v1/account-rollup', accountRollupRouter)
|
||||||
app.use('/v1/connections', connectionsRouter)
|
app.use('/v1/connections', connectionsRouter)
|
||||||
|
|
|
@ -240,6 +240,7 @@ const userService = new UserService(
|
||||||
const institutionProviderFactory = new InstitutionProviderFactory({
|
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||||
PLAID: plaidService,
|
PLAID: plaidService,
|
||||||
FINICITY: finicityService,
|
FINICITY: finicityService,
|
||||||
|
TELLER: tellerService,
|
||||||
})
|
})
|
||||||
|
|
||||||
const institutionService: IInstitutionService = new InstitutionService(
|
const institutionService: IInstitutionService = new InstitutionService(
|
||||||
|
|
|
@ -2,17 +2,20 @@ import cookieParser from 'cookie-parser'
|
||||||
import { decode } from 'next-auth/jwt'
|
import { decode } from 'next-auth/jwt'
|
||||||
|
|
||||||
const SECRET = process.env.NEXTAUTH_SECRET ?? 'REPLACE_THIS'
|
const SECRET = process.env.NEXTAUTH_SECRET ?? 'REPLACE_THIS'
|
||||||
|
|
||||||
export const validateAuthJwt = async (req, res, next) => {
|
export const validateAuthJwt = async (req, res, next) => {
|
||||||
cookieParser(SECRET)(req, res, async (err) => {
|
cookieParser(SECRET)(req, res, async (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.cookies && 'next-auth.session-token' in req.cookies) {
|
const cookieName = req.secure
|
||||||
|
? '__Secure-next-auth.session-token'
|
||||||
|
: 'next-auth.session-token'
|
||||||
|
|
||||||
|
if (req.cookies && cookieName in req.cookies) {
|
||||||
try {
|
try {
|
||||||
const token = await decode({
|
const token = await decode({
|
||||||
token: req.cookies['next-auth.session-token'],
|
token: req.cookies[cookieName],
|
||||||
secret: SECRET,
|
secret: SECRET,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -26,6 +29,18 @@ export const validateAuthJwt = async (req, res, next) => {
|
||||||
console.error('Error in token validation', error)
|
console.error('Error in token validation', error)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
}
|
}
|
||||||
|
} else if (req.headers.authorization) {
|
||||||
|
const token = req.headers.authorization.split(' ')[1]
|
||||||
|
const decoded = await decode({
|
||||||
|
token,
|
||||||
|
secret: SECRET,
|
||||||
|
})
|
||||||
|
if (decoded) {
|
||||||
|
req.user = decoded
|
||||||
|
return next()
|
||||||
|
} else {
|
||||||
|
return res.status(401).json({ message: 'Unauthorized' })
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return res.status(401).json({ message: 'Unauthorized' })
|
return res.status(401).json({ message: 'Unauthorized' })
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ export { default as usersRouter } from './users.router'
|
||||||
export { default as webhooksRouter } from './webhooks.router'
|
export { default as webhooksRouter } from './webhooks.router'
|
||||||
export { default as plaidRouter } from './plaid.router'
|
export { default as plaidRouter } from './plaid.router'
|
||||||
export { default as finicityRouter } from './finicity.router'
|
export { default as finicityRouter } from './finicity.router'
|
||||||
|
export { default as tellerRouter } from './teller.router'
|
||||||
export { default as valuationsRouter } from './valuations.router'
|
export { default as valuationsRouter } from './valuations.router'
|
||||||
export { default as institutionsRouter } from './institutions.router'
|
export { default as institutionsRouter } from './institutions.router'
|
||||||
export { default as transactionsRouter } from './transactions.router'
|
export { default as transactionsRouter } from './transactions.router'
|
||||||
|
|
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_DSN: z.string().optional(),
|
||||||
NX_SENTRY_ENV: z.string().optional(),
|
NX_SENTRY_ENV: z.string().optional(),
|
||||||
|
|
||||||
NX_LD_SDK_KEY: z.string().default('REPLACE_THIS'),
|
|
||||||
|
|
||||||
NX_POLYGON_API_KEY: z.string().default(''),
|
NX_POLYGON_API_KEY: z.string().default(''),
|
||||||
|
|
||||||
NX_PORT: z.string().default('3333'),
|
NX_PORT: z.string().default('3333'),
|
||||||
|
|
|
@ -68,8 +68,8 @@ describe('Finicity', () => {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
name: 'TEST_FINICITY',
|
name: 'TEST_FINICITY',
|
||||||
type: 'finicity',
|
type: 'finicity',
|
||||||
finicityInstitutionId: 'REPLACE_THIS',
|
finicityInstitutionId: '101732',
|
||||||
finicityInstitutionLoginId: 'REPLACE_THIS',
|
finicityInstitutionLoginId: '6000483842',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -1,22 +1,28 @@
|
||||||
import type { PrismaClient, User } from '@prisma/client'
|
import type { PrismaClient, User } from '@prisma/client'
|
||||||
|
import { faker } from '@faker-js/faker'
|
||||||
|
|
||||||
export async function resetUser(prisma: PrismaClient, authId = 'TODO'): Promise<User> {
|
export async function resetUser(prisma: PrismaClient, authId = '__TEST_USER_ID__'): Promise<User> {
|
||||||
// eslint-disable-next-line
|
try {
|
||||||
const [_, __, ___, user] = await prisma.$transaction([
|
// eslint-disable-next-line
|
||||||
prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${authId};`,
|
const [_, __, ___, user] = await prisma.$transaction([
|
||||||
|
prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${authId};`,
|
||||||
|
|
||||||
// Deleting a user does not cascade to securities, so delete all security records
|
// Deleting a user does not cascade to securities, so delete all security records
|
||||||
prisma.$executeRaw`DELETE from security;`,
|
prisma.$executeRaw`DELETE from security;`,
|
||||||
prisma.$executeRaw`DELETE from security_pricing;`,
|
prisma.$executeRaw`DELETE from security_pricing;`,
|
||||||
|
|
||||||
prisma.user.create({
|
prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
authId,
|
authId,
|
||||||
email: 'test@example.com',
|
email: faker.internet.email(),
|
||||||
finicityCustomerId: 'TEST',
|
finicityCustomerId: faker.string.uuid(),
|
||||||
},
|
tellerUserId: faker.string.uuid(),
|
||||||
}),
|
},
|
||||||
])
|
}),
|
||||||
|
])
|
||||||
return user
|
return user
|
||||||
|
} catch (e) {
|
||||||
|
console.error('error in reset user transaction', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
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({
|
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||||
PLAID: plaidService,
|
PLAID: plaidService,
|
||||||
FINICITY: finicityService,
|
FINICITY: finicityService,
|
||||||
|
TELLER: tellerService,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const institutionService: IInstitutionService = new InstitutionService(
|
export const institutionService: IInstitutionService = new InstitutionService(
|
||||||
|
|
|
@ -22,8 +22,6 @@ const envSchema = z.object({
|
||||||
NX_SENTRY_DSN: z.string().optional(),
|
NX_SENTRY_DSN: z.string().optional(),
|
||||||
NX_SENTRY_ENV: z.string().optional(),
|
NX_SENTRY_ENV: z.string().optional(),
|
||||||
|
|
||||||
NX_LD_SDK_KEY: z.string().default('REPLACE_THIS'),
|
|
||||||
|
|
||||||
NX_REDIS_URL: z.string().default('redis://localhost:6379'),
|
NX_REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||||
|
|
||||||
NX_POLYGON_API_KEY: z.string().default(''),
|
NX_POLYGON_API_KEY: z.string().default(''),
|
||||||
|
|
|
@ -115,6 +115,11 @@ syncInstitutionQueue.process(
|
||||||
async () => await institutionService.sync('FINICITY')
|
async () => await institutionService.sync('FINICITY')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
syncInstitutionQueue.process(
|
||||||
|
'sync-teller-institutions',
|
||||||
|
async () => await institutionService.sync('TELLER')
|
||||||
|
)
|
||||||
|
|
||||||
syncInstitutionQueue.add(
|
syncInstitutionQueue.add(
|
||||||
'sync-plaid-institutions',
|
'sync-plaid-institutions',
|
||||||
{},
|
{},
|
||||||
|
@ -131,6 +136,14 @@ syncInstitutionQueue.add(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
syncInstitutionQueue.add(
|
||||||
|
'sync-teller-institutions',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* send-email queue
|
* send-email queue
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -7,12 +7,14 @@ import {
|
||||||
useDebounce,
|
useDebounce,
|
||||||
usePlaid,
|
usePlaid,
|
||||||
useFinicity,
|
useFinicity,
|
||||||
|
useTellerConfig,
|
||||||
|
useTellerConnect,
|
||||||
} from '@maybe-finance/client/shared'
|
} from '@maybe-finance/client/shared'
|
||||||
|
|
||||||
import { Input } from '@maybe-finance/design-system'
|
import { Input } from '@maybe-finance/design-system'
|
||||||
import InstitutionGrid from './InstitutionGrid'
|
import InstitutionGrid from './InstitutionGrid'
|
||||||
import { AccountTypeGrid } from './AccountTypeGrid'
|
import { AccountTypeGrid } from './AccountTypeGrid'
|
||||||
import InstitutionList, { MIN_QUERY_LENGTH } from './InstitutionList'
|
import InstitutionList, { MIN_QUERY_LENGTH } from './InstitutionList'
|
||||||
|
import { useLogger } from '@maybe-finance/client/shared'
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 300
|
const SEARCH_DEBOUNCE_MS = 300
|
||||||
|
|
||||||
|
@ -23,6 +25,7 @@ export default function AccountTypeSelector({
|
||||||
view: string
|
view: string
|
||||||
onViewChange: (view: string) => void
|
onViewChange: (view: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
const logger = useLogger()
|
||||||
const { setAccountManager } = useAccountContext()
|
const { setAccountManager } = useAccountContext()
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||||
|
@ -33,8 +36,11 @@ export default function AccountTypeSelector({
|
||||||
debouncedSearchQuery.length >= MIN_QUERY_LENGTH &&
|
debouncedSearchQuery.length >= MIN_QUERY_LENGTH &&
|
||||||
view !== 'manual'
|
view !== 'manual'
|
||||||
|
|
||||||
|
const config = useTellerConfig(logger)
|
||||||
|
|
||||||
const { openPlaid } = usePlaid()
|
const { openPlaid } = usePlaid()
|
||||||
const { openFinicity } = useFinicity()
|
const { openFinicity } = useFinicity()
|
||||||
|
const { open: openTeller } = useTellerConnect(config, logger)
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
@ -77,6 +83,9 @@ export default function AccountTypeSelector({
|
||||||
case 'FINICITY':
|
case 'FINICITY':
|
||||||
openFinicity(providerInstitution.providerId)
|
openFinicity(providerInstitution.providerId)
|
||||||
break
|
break
|
||||||
|
case 'TELLER':
|
||||||
|
openTeller(providerInstitution.providerId)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -138,11 +147,12 @@ export default function AccountTypeSelector({
|
||||||
categoryUser: 'crypto',
|
categoryUser: 'crypto',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) return
|
if (!data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch (data.provider) {
|
switch (data.provider) {
|
||||||
case 'PLAID':
|
case 'PLAID':
|
||||||
|
@ -151,6 +161,9 @@ export default function AccountTypeSelector({
|
||||||
case 'FINICITY':
|
case 'FINICITY':
|
||||||
openFinicity(data.providerId)
|
openFinicity(data.providerId)
|
||||||
break
|
break
|
||||||
|
case 'TELLER':
|
||||||
|
openTeller(data.providerId)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,48 +14,48 @@ const banks: GridImage[] = [
|
||||||
src: 'chase-bank.png',
|
src: 'chase-bank.png',
|
||||||
alt: 'Chase Bank',
|
alt: 'Chase Bank',
|
||||||
institution: {
|
institution: {
|
||||||
provider: 'PLAID',
|
provider: 'TELLER',
|
||||||
providerId: 'ins_56',
|
providerId: 'chase',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'capital-one.png',
|
src: 'capital-one.png',
|
||||||
alt: 'Capital One Bank',
|
alt: 'Capital One Bank',
|
||||||
institution: {
|
institution: {
|
||||||
provider: 'PLAID',
|
provider: 'TELLER',
|
||||||
providerId: 'ins_128026',
|
providerId: 'capital_one',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'wells-fargo.png',
|
src: 'wells-fargo.png',
|
||||||
alt: 'Wells Fargo Bank',
|
alt: 'Wells Fargo Bank',
|
||||||
institution: {
|
institution: {
|
||||||
provider: 'PLAID',
|
provider: 'TELLER',
|
||||||
providerId: 'ins_127991',
|
providerId: 'wells_fargo',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'american-express.png',
|
src: 'american-express.png',
|
||||||
alt: 'American Express Bank',
|
alt: 'American Express Bank',
|
||||||
institution: {
|
institution: {
|
||||||
provider: 'PLAID',
|
provider: 'TELLER',
|
||||||
providerId: 'ins_10',
|
providerId: 'amex',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'bofa.png',
|
src: 'bofa.png',
|
||||||
alt: 'Bank of America',
|
alt: 'Bank of America',
|
||||||
institution: {
|
institution: {
|
||||||
provider: 'PLAID',
|
provider: 'TELLER',
|
||||||
providerId: 'ins_127989',
|
providerId: 'bank_of_america',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'usaa-bank.png',
|
src: 'usaa-bank.png',
|
||||||
alt: 'USAA Bank',
|
alt: 'USAA Bank',
|
||||||
institution: {
|
institution: {
|
||||||
provider: 'PLAID',
|
provider: 'TELLER',
|
||||||
providerId: 'ins_7',
|
providerId: 'usaa',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,6 +5,7 @@ export * from './useFinicityApi'
|
||||||
export * from './useInstitutionApi'
|
export * from './useInstitutionApi'
|
||||||
export * from './useUserApi'
|
export * from './useUserApi'
|
||||||
export * from './usePlaidApi'
|
export * from './usePlaidApi'
|
||||||
|
export * from './useTellerApi'
|
||||||
export * from './useValuationApi'
|
export * from './useValuationApi'
|
||||||
export * from './useTransactionApi'
|
export * from './useTransactionApi'
|
||||||
export * from './useHoldingApi'
|
export * from './useHoldingApi'
|
||||||
|
|
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 './useScreenSize'
|
||||||
export * from './useAccountNotifications'
|
export * from './useAccountNotifications'
|
||||||
export * from './usePlaid'
|
export * from './usePlaid'
|
||||||
|
export * from './useTeller'
|
||||||
export * from './useProviderStatus'
|
export * from './useProviderStatus'
|
||||||
export * from './useModalManager'
|
export * from './useModalManager'
|
||||||
|
|
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: 'idle' }
|
||||||
| { view: 'add-plaid'; linkToken: string }
|
| { view: 'add-plaid'; linkToken: string }
|
||||||
| { view: 'add-finicity' }
|
| { view: 'add-finicity' }
|
||||||
|
| { view: 'add-teller' }
|
||||||
| { view: 'add-account' }
|
| { view: 'add-account' }
|
||||||
| { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> }
|
| { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> }
|
||||||
| { view: 'add-vehicle'; defaultValues: Partial<CreateVehicleFields> }
|
| { view: 'add-vehicle'; defaultValues: Partial<CreateVehicleFields> }
|
||||||
|
|
|
@ -3,9 +3,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { DatePicker } from './'
|
import { DatePicker } from './'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
|
|
||||||
// Set date to Oct 29, 2021 to keep snapshots consistent
|
|
||||||
beforeAll(() => jest.useFakeTimers().setSystemTime(new Date('2021-10-29 12:00:00')))
|
|
||||||
|
|
||||||
// DatePicker configuration
|
// DatePicker configuration
|
||||||
const minDate = DateTime.now().minus({ years: 2 })
|
const minDate = DateTime.now().minus({ years: 2 })
|
||||||
const maxDate = DateTime.now()
|
const maxDate = DateTime.now()
|
||||||
|
|
|
@ -73,7 +73,7 @@ export function DatePickerCalendar({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="text-white hover:bg-gray-500 flex grow justify-center items-center rounded"
|
className="text-white hover:bg-gray-500 flex grow justify-center items-center rounded"
|
||||||
data-testid="datepicker-range-month-button"
|
data-testid="datepicker-range-year-button"
|
||||||
onClick={() => setView('year')}
|
onClick={() => setView('year')}
|
||||||
>
|
>
|
||||||
{calendars[0].year}
|
{calendars[0].year}
|
||||||
|
|
|
@ -103,7 +103,7 @@ export function DatePickerRangeCalendar({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="text-white hover:bg-gray-500 flex grow justify-center items-center rounded"
|
className="text-white hover:bg-gray-500 flex grow justify-center items-center rounded"
|
||||||
data-testid="datepicker-range-month-button"
|
data-testid="datepicker-range-year-button"
|
||||||
onClick={() => setView('year')}
|
onClick={() => setView('year')}
|
||||||
>
|
>
|
||||||
{calendars[0].year}
|
{calendars[0].year}
|
||||||
|
|
|
@ -271,7 +271,7 @@ export class InstitutionService implements IInstitutionService {
|
||||||
provider_institution pi
|
provider_institution pi
|
||||||
SET
|
SET
|
||||||
institution_id = i.id,
|
institution_id = i.id,
|
||||||
rank = (CASE WHEN pi.provider = 'PLAID' THEN 1 ELSE 0 END)
|
rank = (CASE WHEN pi.provider = 'TELLER' THEN 1 ELSE 0 END)
|
||||||
FROM
|
FROM
|
||||||
duplicates d
|
duplicates d
|
||||||
INNER JOIN institutions i ON i.name = d.name AND i.url = d.url
|
INNER JOIN institutions i ON i.name = d.name AND i.url = d.url
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { AccountConnection, PrismaClient } from '@prisma/client'
|
import type { AccountConnection, PrismaClient } from '@prisma/client'
|
||||||
import type { Logger } from 'winston'
|
import type { Logger } from 'winston'
|
||||||
import { SharedUtil, AccountUtil, type SharedType } from '@maybe-finance/shared'
|
import { AccountUtil, SharedUtil, type SharedType } from '@maybe-finance/shared'
|
||||||
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
|
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
|
||||||
import { DbUtil, TellerUtil, type IETL, type ICryptoService } from '@maybe-finance/server/shared'
|
import { DbUtil, TellerUtil, type IETL, type ICryptoService } from '@maybe-finance/server/shared'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
|
@ -101,16 +101,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
|
|
||||||
private async _extractAccounts(accessToken: string) {
|
private async _extractAccounts(accessToken: string) {
|
||||||
const accounts = await this.teller.getAccounts({ accessToken })
|
const accounts = await this.teller.getAccounts({ accessToken })
|
||||||
const accountsWithBalances = await Promise.all(
|
return accounts
|
||||||
accounts.map(async (a) => {
|
|
||||||
const balance = await this.teller.getAccountBalances({
|
|
||||||
accountId: a.id,
|
|
||||||
accessToken,
|
|
||||||
})
|
|
||||||
return { ...a, balance }
|
|
||||||
})
|
|
||||||
)
|
|
||||||
return accountsWithBalances
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _loadAccounts(connection: Connection, { accounts }: Pick<TellerData, 'accounts'>) {
|
private _loadAccounts(connection: Connection, { accounts }: Pick<TellerData, 'accounts'>) {
|
||||||
|
@ -119,6 +110,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
...accounts.map((tellerAccount) => {
|
...accounts.map((tellerAccount) => {
|
||||||
const type = TellerUtil.getType(tellerAccount.type)
|
const type = TellerUtil.getType(tellerAccount.type)
|
||||||
const classification = AccountUtil.getClassification(type)
|
const classification = AccountUtil.getClassification(type)
|
||||||
|
|
||||||
return this.prisma.account.upsert({
|
return this.prisma.account.upsert({
|
||||||
where: {
|
where: {
|
||||||
accountConnectionId_tellerAccountId: {
|
accountConnectionId_tellerAccountId: {
|
||||||
|
@ -132,6 +124,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type),
|
categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type),
|
||||||
subcategoryProvider: tellerAccount.subtype ?? 'other',
|
subcategoryProvider: tellerAccount.subtype ?? 'other',
|
||||||
accountConnectionId: connection.id,
|
accountConnectionId: connection.id,
|
||||||
|
userId: connection.userId,
|
||||||
tellerAccountId: tellerAccount.id,
|
tellerAccountId: tellerAccount.id,
|
||||||
name: tellerAccount.name,
|
name: tellerAccount.name,
|
||||||
tellerType: tellerAccount.type,
|
tellerType: tellerAccount.type,
|
||||||
|
@ -170,27 +163,21 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
|
|
||||||
private async _extractTransactions(accessToken: string, accountIds: string[]) {
|
private async _extractTransactions(accessToken: string, accountIds: string[]) {
|
||||||
const accountTransactions = await Promise.all(
|
const accountTransactions = await Promise.all(
|
||||||
accountIds.map((accountId) =>
|
accountIds.map(async (accountId) => {
|
||||||
SharedUtil.paginate({
|
const transactions = await SharedUtil.withRetry(
|
||||||
pageSize: 1000, // TODO: Check with Teller on max page size
|
() =>
|
||||||
fetchData: async () => {
|
this.teller.getTransactions({
|
||||||
const transactions = await SharedUtil.withRetry(
|
accountId,
|
||||||
() =>
|
accessToken,
|
||||||
this.teller.getTransactions({
|
}),
|
||||||
accountId,
|
{
|
||||||
accessToken: accessToken,
|
maxRetries: 3,
|
||||||
}),
|
}
|
||||||
{
|
)
|
||||||
maxRetries: 3,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return transactions
|
return transactions
|
||||||
},
|
})
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return accountTransactions.flat()
|
return accountTransactions.flat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -210,7 +197,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
${Prisma.join(
|
${Prisma.join(
|
||||||
chunk.map((tellerTransaction) => {
|
chunk.map((tellerTransaction) => {
|
||||||
const {
|
const {
|
||||||
id,
|
id: transactionId,
|
||||||
account_id,
|
account_id,
|
||||||
description,
|
description,
|
||||||
amount,
|
amount,
|
||||||
|
@ -224,15 +211,15 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
(SELECT id FROM account WHERE account_connection_id = ${
|
(SELECT id FROM account WHERE account_connection_id = ${
|
||||||
connection.id
|
connection.id
|
||||||
} AND teller_account_id = ${account_id.toString()}),
|
} AND teller_account_id = ${account_id.toString()}),
|
||||||
${id},
|
${transactionId},
|
||||||
${date}::date,
|
${date}::date,
|
||||||
${[description].filter(Boolean).join(' ')},
|
${description},
|
||||||
${DbUtil.toDecimal(-amount)},
|
${DbUtil.toDecimal(Number(amount))},
|
||||||
${status === 'pending'},
|
${status === 'pending'},
|
||||||
${'USD'},
|
${'USD'},
|
||||||
${details.counterparty.name ?? ''},
|
${details.counterparty?.name ?? ''},
|
||||||
${type},
|
${type},
|
||||||
${details.category ?? ''},
|
${details.category ?? ''}
|
||||||
)`
|
)`
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -6,19 +6,11 @@ import type {
|
||||||
IAccountConnectionProvider,
|
IAccountConnectionProvider,
|
||||||
} from '../../account-connection'
|
} from '../../account-connection'
|
||||||
import { SharedUtil } from '@maybe-finance/shared'
|
import { SharedUtil } from '@maybe-finance/shared'
|
||||||
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
import type { SyncConnectionOptions, CryptoService, IETL } from '@maybe-finance/server/shared'
|
import type { SyncConnectionOptions, CryptoService, IETL } from '@maybe-finance/server/shared'
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
import { ErrorUtil, etl } from '@maybe-finance/server/shared'
|
import { ErrorUtil, etl } from '@maybe-finance/server/shared'
|
||||||
import type { TellerApi } from '@maybe-finance/teller-api'
|
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
|
||||||
|
|
||||||
export interface ITellerConnect {
|
|
||||||
generateConnectUrl(userId: User['id'], institutionId: string): Promise<{ link: string }>
|
|
||||||
|
|
||||||
generateFixConnectUrl(
|
|
||||||
userId: User['id'],
|
|
||||||
accountConnectionId: AccountConnection['id']
|
|
||||||
): Promise<{ link: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TellerService implements IAccountConnectionProvider, IInstitutionProvider {
|
export class TellerService implements IAccountConnectionProvider, IInstitutionProvider {
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -44,6 +36,7 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
|
||||||
where: { id: connection.id },
|
where: { id: connection.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'OK',
|
status: 'OK',
|
||||||
|
syncStatus: 'IDLE',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
@ -67,19 +60,28 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
|
||||||
|
|
||||||
async delete(connection: AccountConnection) {
|
async delete(connection: AccountConnection) {
|
||||||
// purge teller data
|
// purge teller data
|
||||||
if (connection.tellerAccessToken && connection.tellerAccountId) {
|
if (connection.tellerAccessToken && connection.tellerEnrollmentId) {
|
||||||
await this.teller.deleteAccount({
|
const accounts = await this.prisma.account.findMany({
|
||||||
accessToken: this.crypto.decrypt(connection.tellerAccessToken),
|
where: { accountConnectionId: connection.id },
|
||||||
accountId: connection.tellerAccountId,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.logger.info(`Item ${connection.tellerAccountId} removed`)
|
for (const account of accounts) {
|
||||||
|
if (!account.tellerAccountId) continue
|
||||||
|
await this.teller.deleteAccount({
|
||||||
|
accessToken: this.crypto.decrypt(connection.tellerAccessToken),
|
||||||
|
accountId: account.tellerAccountId,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.logger.info(`Teller account ${account.id} removed`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(`Teller enrollment ${connection.tellerEnrollmentId} removed`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInstitutions() {
|
async getInstitutions() {
|
||||||
const tellerInstitutions = await SharedUtil.paginate({
|
const tellerInstitutions = await SharedUtil.paginate({
|
||||||
pageSize: 500,
|
pageSize: 10000,
|
||||||
delay:
|
delay:
|
||||||
process.env.NODE_ENV !== 'production'
|
process.env.NODE_ENV !== 'production'
|
||||||
? {
|
? {
|
||||||
|
@ -87,20 +89,20 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
|
||||||
milliseconds: 7_000, // Sandbox rate limited at 10 calls / minute
|
milliseconds: 7_000, // Sandbox rate limited at 10 calls / minute
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
fetchData: (offset, count) =>
|
fetchData: () =>
|
||||||
SharedUtil.withRetry(
|
SharedUtil.withRetry(
|
||||||
() =>
|
() =>
|
||||||
this.teller.getInstitutions().then((data) => {
|
this.teller.getInstitutions().then((data) => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`paginated teller fetch inst=${data.institutions.length} (total=${data.institutions.length} offset=${offset} count=${count})`
|
`teller fetch inst=${data.length} (total=${data.length})`
|
||||||
)
|
)
|
||||||
return data.institutions
|
return data
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
onError: (error, attempt) => {
|
onError: (error, attempt) => {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Teller fetch institutions request failed attempt=${attempt} offset=${offset} count=${count}`,
|
`Teller fetch institutions request failed attempt=${attempt}`,
|
||||||
{ error: ErrorUtil.parseError(error) }
|
{ error: ErrorUtil.parseError(error) }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -115,12 +117,57 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
|
||||||
return {
|
return {
|
||||||
providerId: id,
|
providerId: id,
|
||||||
name,
|
name,
|
||||||
url: undefined,
|
url: null,
|
||||||
logo: `https://teller.io/images/banks/${id}.jpg}`,
|
logo: null,
|
||||||
primaryColor: undefined,
|
logoUrl: `https://teller.io/images/banks/${id}.jpg`,
|
||||||
oauth: undefined,
|
primaryColor: null,
|
||||||
|
oauth: false,
|
||||||
data: tellerInstitution,
|
data: tellerInstitution,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleEnrollment(
|
||||||
|
userId: User['id'],
|
||||||
|
institution: Pick<TellerTypes.Institution, 'name' | 'id'>,
|
||||||
|
enrollment: TellerTypes.Enrollment
|
||||||
|
) {
|
||||||
|
const connections = await this.prisma.accountConnection.findMany({
|
||||||
|
where: { userId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (connections.length > 40) {
|
||||||
|
throw new Error('MAX_ACCOUNT_CONNECTIONS')
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await this.teller.getAccounts({ accessToken: enrollment.accessToken })
|
||||||
|
|
||||||
|
this.logger.info(`Teller accounts retrieved for enrollment ${enrollment.enrollment.id}`)
|
||||||
|
|
||||||
|
// If all the accounts are Non-USD, throw an error
|
||||||
|
if (accounts.every((a) => a.currency !== 'USD')) {
|
||||||
|
throw new Error('USD_ONLY')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
tellerUserId: enrollment.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const accountConnection = await this.prisma.accountConnection.create({
|
||||||
|
data: {
|
||||||
|
name: enrollment.enrollment.institution.name,
|
||||||
|
type: 'teller' as SharedType.AccountConnectionType,
|
||||||
|
tellerEnrollmentId: enrollment.enrollment.id,
|
||||||
|
tellerInstitutionId: institution.id,
|
||||||
|
tellerAccessToken: this.crypto.encrypt(enrollment.accessToken),
|
||||||
|
userId,
|
||||||
|
syncStatus: 'PENDING',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return accountConnection
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import crypto from 'crypto'
|
import CryptoJS from 'crypto-js'
|
||||||
|
|
||||||
export interface ICryptoService {
|
export interface ICryptoService {
|
||||||
encrypt(plainText: string): string
|
encrypt(plainText: string): string
|
||||||
|
@ -6,32 +6,13 @@ export interface ICryptoService {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CryptoService implements ICryptoService {
|
export class CryptoService implements ICryptoService {
|
||||||
private key: Buffer
|
constructor(private readonly secret: string) {}
|
||||||
private ivLength = 16 // Initialization vector length. For AES, this is always 16
|
|
||||||
|
|
||||||
constructor(private readonly secret: string) {
|
|
||||||
// Ensure the key length is suitable for AES-256
|
|
||||||
this.key = crypto.createHash('sha256').update(String(this.secret)).digest()
|
|
||||||
}
|
|
||||||
|
|
||||||
encrypt(plainText: string) {
|
encrypt(plainText: string) {
|
||||||
const iv = crypto.randomBytes(this.ivLength)
|
return CryptoJS.AES.encrypt(plainText, this.secret).toString()
|
||||||
const cipher = crypto.createCipheriv('aes-256-cbc', this.key, iv)
|
|
||||||
let encrypted = cipher.update(plainText, 'utf8', 'hex')
|
|
||||||
encrypted += cipher.final('hex')
|
|
||||||
|
|
||||||
// Include the IV at the start of the encrypted result
|
|
||||||
return iv.toString('hex') + ':' + encrypted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
decrypt(encrypted: string) {
|
decrypt(encrypted: string) {
|
||||||
const textParts = encrypted.split(':')
|
return CryptoJS.AES.decrypt(encrypted, this.secret).toString(CryptoJS.enc.Utf8)
|
||||||
const iv = Buffer.from(textParts.shift()!, 'hex')
|
|
||||||
const encryptedText = textParts.join(':')
|
|
||||||
const decipher = crypto.createDecipheriv('aes-256-cbc', this.key, iv)
|
|
||||||
let decrypted = decipher.update(encryptedText, 'hex', 'utf8')
|
|
||||||
decrypted += decipher.final('utf8')
|
|
||||||
|
|
||||||
return decrypted
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -70,7 +70,7 @@ export type SyncSecurityQueue = IQueue<SyncSecurityQueueJobData, 'sync-all-secur
|
||||||
export type PurgeUserQueue = IQueue<{ userId: User['id'] }, 'purge-user'>
|
export type PurgeUserQueue = IQueue<{ userId: User['id'] }, 'purge-user'>
|
||||||
export type SyncInstitutionQueue = IQueue<
|
export type SyncInstitutionQueue = IQueue<
|
||||||
{},
|
{},
|
||||||
'sync-finicity-institutions' | 'sync-plaid-institutions'
|
'sync-finicity-institutions' | 'sync-plaid-institutions' | 'sync-teller-institutions'
|
||||||
>
|
>
|
||||||
export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>
|
export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>
|
||||||
|
|
||||||
|
|
|
@ -2,8 +2,8 @@ import {
|
||||||
Prisma,
|
Prisma,
|
||||||
AccountCategory,
|
AccountCategory,
|
||||||
AccountType,
|
AccountType,
|
||||||
type AccountClassification,
|
|
||||||
type Account,
|
type Account,
|
||||||
|
type AccountClassification,
|
||||||
} from '@prisma/client'
|
} from '@prisma/client'
|
||||||
import type { TellerTypes } from '@maybe-finance/teller-api'
|
import type { TellerTypes } from '@maybe-finance/teller-api'
|
||||||
import { Duration } from 'luxon'
|
import { Duration } from 'luxon'
|
||||||
|
@ -11,10 +11,10 @@ import { Duration } from 'luxon'
|
||||||
/**
|
/**
|
||||||
* Update this with the max window that Teller supports
|
* Update this with the max window that Teller supports
|
||||||
*/
|
*/
|
||||||
export const TELLER_WINDOW_MAX = Duration.fromObject({ years: 1 })
|
export const TELLER_WINDOW_MAX = Duration.fromObject({ years: 2 })
|
||||||
|
|
||||||
export function getAccountBalanceData(
|
export function getAccountBalanceData(
|
||||||
{ balances, currency }: Pick<TellerTypes.AccountWithBalances, 'balances' | 'currency'>,
|
{ balance, currency }: Pick<TellerTypes.AccountWithBalances, 'balance' | 'currency'>,
|
||||||
classification: AccountClassification
|
classification: AccountClassification
|
||||||
): Pick<
|
): Pick<
|
||||||
Account,
|
Account,
|
||||||
|
@ -24,16 +24,11 @@ export function getAccountBalanceData(
|
||||||
| 'availableBalanceStrategy'
|
| 'availableBalanceStrategy'
|
||||||
| 'currencyCode'
|
| 'currencyCode'
|
||||||
> {
|
> {
|
||||||
// Flip balance values to positive for liabilities
|
|
||||||
const sign = classification === 'liability' ? -1 : 1
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentBalanceProvider: new Prisma.Decimal(
|
currentBalanceProvider: new Prisma.Decimal(balance.ledger ? Number(balance.ledger) : 0),
|
||||||
balances.ledger ? sign * Number(balances.ledger) : 0
|
|
||||||
),
|
|
||||||
currentBalanceStrategy: 'current',
|
currentBalanceStrategy: 'current',
|
||||||
availableBalanceProvider: new Prisma.Decimal(
|
availableBalanceProvider: new Prisma.Decimal(
|
||||||
balances.available ? sign * Number(balances.available) : 0
|
balance.available ? Number(balance.available) : 0
|
||||||
),
|
),
|
||||||
availableBalanceStrategy: 'available',
|
availableBalanceStrategy: 'available',
|
||||||
currencyCode: currency,
|
currencyCode: currency,
|
||||||
|
|
|
@ -34,7 +34,20 @@ export class TellerApi {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
async getAccounts({ accessToken }: AuthenticatedRequest): Promise<GetAccountsResponse> {
|
async getAccounts({ accessToken }: AuthenticatedRequest): Promise<GetAccountsResponse> {
|
||||||
return this.get<GetAccountsResponse>(`/accounts`, accessToken)
|
const accounts = await this.get<GetAccountsResponse>(`/accounts`, accessToken)
|
||||||
|
const accountsWithBalances = await Promise.all(
|
||||||
|
accounts.map(async (account) => {
|
||||||
|
const balance = await this.getAccountBalances({
|
||||||
|
accountId: account.id,
|
||||||
|
accessToken,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
balance,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return accountsWithBalances
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -137,12 +150,12 @@ export class TellerApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getApi(accessToken: string): Promise<AxiosInstance> {
|
private async getApi(accessToken: string): Promise<AxiosInstance> {
|
||||||
const cert = fs.readFileSync('../../../certs/teller-certificate.pem', 'utf8')
|
const cert = fs.readFileSync('./certs/certificate.pem')
|
||||||
const key = fs.readFileSync('../../../certs/teller-private-key.pem', 'utf8')
|
const key = fs.readFileSync('./certs/private_key.pem')
|
||||||
|
|
||||||
const agent = new https.Agent({
|
const agent = new https.Agent({
|
||||||
cert,
|
cert: cert,
|
||||||
key,
|
key: key,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!this.api) {
|
if (!this.api) {
|
||||||
|
@ -153,16 +166,16 @@ export class TellerApi {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
|
auth: {
|
||||||
|
username: accessToken,
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
} else if (this.api.defaults.auth?.username !== accessToken) {
|
||||||
this.api.interceptors.request.use((config) => {
|
this.api.defaults.auth = {
|
||||||
// Add the access_token to the auth object
|
username: accessToken,
|
||||||
config.auth = {
|
password: '',
|
||||||
username: 'ACCESS_TOKEN',
|
}
|
||||||
password: accessToken,
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.api
|
return this.api
|
||||||
|
|
|
@ -12,13 +12,15 @@ export enum AccountType {
|
||||||
export type DepositorySubtypes =
|
export type DepositorySubtypes =
|
||||||
| 'checking'
|
| 'checking'
|
||||||
| 'savings'
|
| 'savings'
|
||||||
| 'money market'
|
| 'money_market'
|
||||||
| 'certificate of deposit'
|
| 'certificate_of_deposit'
|
||||||
| 'treasury'
|
| 'treasury'
|
||||||
| 'sweep'
|
| 'sweep'
|
||||||
|
|
||||||
export type CreditSubtype = 'credit_card'
|
export type CreditSubtype = 'credit_card'
|
||||||
|
|
||||||
|
export type AccountStatus = 'open' | 'closed'
|
||||||
|
|
||||||
interface BaseAccount {
|
interface BaseAccount {
|
||||||
enrollment_id: string
|
enrollment_id: string
|
||||||
links: {
|
links: {
|
||||||
|
@ -34,7 +36,7 @@ interface BaseAccount {
|
||||||
currency: string
|
currency: string
|
||||||
id: string
|
id: string
|
||||||
last_four: string
|
last_four: string
|
||||||
status: 'open' | 'closed'
|
status: AccountStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DepositoryAccount extends BaseAccount {
|
interface DepositoryAccount extends BaseAccount {
|
||||||
|
@ -50,10 +52,10 @@ interface CreditAccount extends BaseAccount {
|
||||||
export type Account = DepositoryAccount | CreditAccount
|
export type Account = DepositoryAccount | CreditAccount
|
||||||
|
|
||||||
export type AccountWithBalances = Account & {
|
export type AccountWithBalances = Account & {
|
||||||
balances: AccountBalance
|
balance: AccountBalance
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetAccountsResponse = Account[]
|
export type GetAccountsResponse = AccountWithBalances[]
|
||||||
export type GetAccountResponse = Account
|
export type GetAccountResponse = Account
|
||||||
export type DeleteAccountResponse = void
|
export type DeleteAccountResponse = void
|
||||||
|
|
||||||
|
|
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 './account-details'
|
||||||
export * from './authentication'
|
export * from './authentication'
|
||||||
export * from './error'
|
export * from './error'
|
||||||
|
export * from './enrollment'
|
||||||
export * from './identity'
|
export * from './identity'
|
||||||
export * from './institutions'
|
export * from './institutions'
|
||||||
export * from './transactions'
|
export * from './transactions'
|
||||||
|
|
|
@ -9,6 +9,4 @@ export type Institution = {
|
||||||
|
|
||||||
type Capability = 'detail' | 'balance' | 'transaction' | 'identity'
|
type Capability = 'detail' | 'balance' | 'transaction' | 'identity'
|
||||||
|
|
||||||
export type GetInstitutionsResponse = {
|
export type GetInstitutionsResponse = Institution[]
|
||||||
institutions: Institution[]
|
|
||||||
}
|
|
||||||
|
|
|
@ -38,7 +38,7 @@ export type Transaction = {
|
||||||
details: {
|
details: {
|
||||||
category?: DetailCategory
|
category?: DetailCategory
|
||||||
processing_status: DetailProcessingStatus
|
processing_status: DetailProcessingStatus
|
||||||
counterparty: {
|
counterparty?: {
|
||||||
name?: string
|
name?: string
|
||||||
type?: 'organization' | 'person'
|
type?: 'organization' | 'person'
|
||||||
}
|
}
|
||||||
|
|
|
@ -89,6 +89,7 @@
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bull": "^4.10.2",
|
"bull": "^4.10.2",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
|
"cookie": "^0.6.0",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"core-js": "^3.6.5",
|
"core-js": "^3.6.5",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
@ -107,7 +108,7 @@
|
||||||
"http-errors": "^2.0.0",
|
"http-errors": "^2.0.0",
|
||||||
"ioredis": "^5.2.4",
|
"ioredis": "^5.2.4",
|
||||||
"is-ci": "^3.0.1",
|
"is-ci": "^3.0.1",
|
||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"jwk-to-pem": "^2.0.5",
|
"jwk-to-pem": "^2.0.5",
|
||||||
"jwks-rsa": "^3.0.0",
|
"jwks-rsa": "^3.0.0",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
|
@ -147,12 +148,14 @@
|
||||||
"react-popper": "^2.3.0",
|
"react-popper": "^2.3.0",
|
||||||
"react-ranger": "^2.1.0",
|
"react-ranger": "^2.1.0",
|
||||||
"react-responsive": "^9.0.0-beta.10",
|
"react-responsive": "^9.0.0-beta.10",
|
||||||
|
"react-script-hook": "^1.7.2",
|
||||||
"regenerator-runtime": "0.13.7",
|
"regenerator-runtime": "0.13.7",
|
||||||
"sanitize-html": "^2.8.1",
|
"sanitize-html": "^2.8.1",
|
||||||
"smooth-scroll-into-view-if-needed": "^1.1.33",
|
"smooth-scroll-into-view-if-needed": "^1.1.33",
|
||||||
"stripe": "^10.17.0",
|
"stripe": "^10.17.0",
|
||||||
"superjson": "^1.11.0",
|
"superjson": "^1.11.0",
|
||||||
"tailwindcss": "3.2.4",
|
"tailwindcss": "3.2.4",
|
||||||
|
"teller-connect-react": "^0.1.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"winston": "^3.8.2",
|
"winston": "^3.8.2",
|
||||||
|
@ -163,6 +166,7 @@
|
||||||
"@babel/core": "7.17.5",
|
"@babel/core": "7.17.5",
|
||||||
"@babel/preset-react": "^7.14.5",
|
"@babel/preset-react": "^7.14.5",
|
||||||
"@babel/preset-typescript": "7.16.7",
|
"@babel/preset-typescript": "7.16.7",
|
||||||
|
"@faker-js/faker": "^8.3.1",
|
||||||
"@fast-csv/parse": "^4.3.6",
|
"@fast-csv/parse": "^4.3.6",
|
||||||
"@next/bundle-analyzer": "^13.1.1",
|
"@next/bundle-analyzer": "^13.1.1",
|
||||||
"@nrwl/cli": "15.5.2",
|
"@nrwl/cli": "15.5.2",
|
||||||
|
|
|
@ -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")
|
finicityError Json? @map("finicity_error")
|
||||||
|
|
||||||
// teller data
|
// teller data
|
||||||
tellerAccountId String? @map("teller_account_id")
|
|
||||||
tellerAccessToken String? @map("teller_access_token")
|
tellerAccessToken String? @map("teller_access_token")
|
||||||
|
tellerEnrollmentId String? @map("teller_enrollment_id")
|
||||||
tellerInstitutionId String? @map("teller_institution_id")
|
tellerInstitutionId String? @map("teller_institution_id")
|
||||||
tellerError Json? @map("teller_error")
|
tellerError Json? @map("teller_error")
|
||||||
|
|
||||||
|
@ -340,7 +340,7 @@ model Transaction {
|
||||||
currencyCode String @default("USD") @map("currency_code")
|
currencyCode String @default("USD") @map("currency_code")
|
||||||
pending Boolean @default(false)
|
pending Boolean @default(false)
|
||||||
merchantName String? @map("merchant_name")
|
merchantName String? @map("merchant_name")
|
||||||
category String @default(dbgenerated("COALESCE(category_user,\nCASE\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'INCOME'::text) THEN 'Income'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = ANY (ARRAY['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text])) THEN 'Housing Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = 'LOAN_PAYMENTS_CAR_PAYMENT'::text) THEN 'Vehicle Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'LOAN_PAYMENTS'::text) THEN 'Other Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'HOME_IMPROVEMENT'::text) THEN 'Home Improvement'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'GENERAL_MERCHANDISE'::text) THEN 'Shopping'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = 'RENT_AND_UTILITIES'::text) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'RENT_AND_UTILITIES_RENT'::text)) THEN 'Utilities'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'FOOD_AND_DRINK'::text) THEN 'Food and Drink'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRANSPORTATION'::text) THEN 'Transportation'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRAVEL'::text) THEN 'Travel'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = ANY (ARRAY['PERSONAL_CARE'::text, 'MEDICAL'::text])) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'MEDICAL_VETERINARY_SERVICES'::text)) THEN 'Health'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Income'::text, 'Paycheck'::text])) THEN 'Income'::text\n WHEN ((finicity_categorization ->> 'category'::text) = 'Mortgage & Rent'::text) THEN 'Housing Payments'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Furnishings'::text, 'Home Services'::text, 'Home Improvement'::text, 'Lawn and Garden'::text])) THEN 'Home Improvement'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Streaming Services'::text, 'Home Phone'::text, 'Television'::text, 'Bills & Utilities'::text, 'Utilities'::text, 'Internet / Broadband Charges'::text, 'Mobile Phone'::text])) THEN 'Utilities'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Fast Food'::text, 'Food & Dining'::text, 'Restaurants'::text, 'Coffee Shops'::text, 'Alcohol & Bars'::text, 'Groceries'::text])) THEN 'Food and Drink'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Auto & Transport'::text, 'Gas & Fuel'::text, 'Auto Insurance'::text])) THEN 'Transportation'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Hotel'::text, 'Travel'::text, 'Rental Car & Taxi'::text])) THEN 'Travel'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Health Insurance'::text, 'Doctor'::text, 'Pharmacy'::text, 'Eyecare'::text, 'Health & Fitness'::text, 'Personal Care'::text])) THEN 'Health'::text\n ELSE 'Other'::text\nEND)"))
|
category String @default(dbgenerated("COALESCE(category_user,\nCASE\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'INCOME'::text) THEN 'Income'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = ANY (ARRAY['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text])) THEN 'Housing Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = 'LOAN_PAYMENTS_CAR_PAYMENT'::text) THEN 'Vehicle Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'LOAN_PAYMENTS'::text) THEN 'Other Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'HOME_IMPROVEMENT'::text) THEN 'Home Improvement'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'GENERAL_MERCHANDISE'::text) THEN 'Shopping'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = 'RENT_AND_UTILITIES'::text) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'RENT_AND_UTILITIES_RENT'::text)) THEN 'Utilities'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'FOOD_AND_DRINK'::text) THEN 'Food and Drink'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRANSPORTATION'::text) THEN 'Transportation'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRAVEL'::text) THEN 'Travel'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = ANY (ARRAY['PERSONAL_CARE'::text, 'MEDICAL'::text])) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'MEDICAL_VETERINARY_SERVICES'::text)) THEN 'Health'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Income'::text, 'Paycheck'::text])) THEN 'Income'::text\n WHEN ((finicity_categorization ->> 'category'::text) = 'Mortgage & Rent'::text) THEN 'Housing Payments'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Furnishings'::text, 'Home Services'::text, 'Home Improvement'::text, 'Lawn and Garden'::text])) THEN 'Home Improvement'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Streaming Services'::text, 'Home Phone'::text, 'Television'::text, 'Bills & Utilities'::text, 'Utilities'::text, 'Internet / Broadband Charges'::text, 'Mobile Phone'::text])) THEN 'Utilities'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Fast Food'::text, 'Food & Dining'::text, 'Restaurants'::text, 'Coffee Shops'::text, 'Alcohol & Bars'::text, 'Groceries'::text])) THEN 'Food and Drink'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Auto & Transport'::text, 'Gas & Fuel'::text, 'Auto Insurance'::text])) THEN 'Transportation'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Hotel'::text, 'Travel'::text, 'Rental Car & Taxi'::text])) THEN 'Travel'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Health Insurance'::text, 'Doctor'::text, 'Pharmacy'::text, 'Eyecare'::text, 'Health & Fitness'::text, 'Personal Care'::text])) THEN 'Health'::text\n WHEN (teller_category = 'income'::text) THEN 'Income'::text\n WHEN (teller_category = 'home'::text) THEN 'Home Improvement'::text\n WHEN (teller_category = ANY (ARRAY['phone'::text, 'utilities'::text])) THEN 'Utilities'::text\n WHEN (teller_category = ANY (ARRAY['dining'::text, 'bar'::text, 'groceries'::text])) THEN 'Food and Drink'::text\n WHEN (teller_category = ANY (ARRAY['clothing'::text, 'entertainment'::text, 'shopping'::text, 'electronics'::text, 'software'::text, 'sport'::text])) THEN 'Shopping'::text\n WHEN (teller_category = ANY (ARRAY['transportation'::text, 'fuel'::text])) THEN 'Transportation'::text\n WHEN (teller_category = ANY (ARRAY['accommodation'::text, 'transport'::text])) THEN 'Travel'::text\n WHEN (teller_category = 'health'::text) THEN 'Health'::text\n WHEN (teller_category = ANY (ARRAY['loan'::text, 'tax'::text, 'insurance'::text, 'office'::text])) THEN 'Other Payments'::text\n ELSE 'Other'::text\nEND)"))
|
||||||
categoryUser String? @map("category_user")
|
categoryUser String? @map("category_user")
|
||||||
excluded Boolean @default(false)
|
excluded Boolean @default(false)
|
||||||
|
|
||||||
|
@ -493,6 +493,7 @@ model Institution {
|
||||||
enum Provider {
|
enum Provider {
|
||||||
PLAID
|
PLAID
|
||||||
FINICITY
|
FINICITY
|
||||||
|
TELLER
|
||||||
}
|
}
|
||||||
|
|
||||||
model ProviderInstitution {
|
model ProviderInstitution {
|
||||||
|
|
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": {
|
"test": {
|
||||||
"executor": "@nrwl/jest:jest",
|
"executor": "@nrwl/jest:jest",
|
||||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
"outputs": ["/coverage/libs/teller-api"],
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "libs/teller-api/jest.config.ts",
|
"jestConfig": "libs/teller-api/jest.config.ts",
|
||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
|
|
73
yarn.lock
73
yarn.lock
|
@ -1730,6 +1730,11 @@
|
||||||
minimatch "^3.1.2"
|
minimatch "^3.1.2"
|
||||||
strip-json-comments "^3.1.1"
|
strip-json-comments "^3.1.1"
|
||||||
|
|
||||||
|
"@faker-js/faker@^8.3.1":
|
||||||
|
version "8.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-8.3.1.tgz#7753df0cb88d7649becf984a96dd1bd0a26f43e3"
|
||||||
|
integrity sha512-FdgpFxY6V6rLZE9mmIBb9hM0xpfvQOSNOLnzolzKwsE1DH+gC7lEKV1p1IbR0lAYyvYd5a4u3qWJzowUkw1bIw==
|
||||||
|
|
||||||
"@fast-csv/format@^4.3.5":
|
"@fast-csv/format@^4.3.5":
|
||||||
version "4.3.5"
|
version "4.3.5"
|
||||||
resolved "https://registry.yarnpkg.com/@fast-csv/format/-/format-4.3.5.tgz#90d83d1b47b6aaf67be70d6118f84f3e12ee1ff3"
|
resolved "https://registry.yarnpkg.com/@fast-csv/format/-/format-4.3.5.tgz#90d83d1b47b6aaf67be70d6118f84f3e12ee1ff3"
|
||||||
|
@ -7983,7 +7988,7 @@ cookie@0.5.0, cookie@^0.5.0:
|
||||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
|
||||||
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
|
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
|
||||||
|
|
||||||
cookie@0.6.0:
|
cookie@0.6.0, cookie@^0.6.0:
|
||||||
version "0.6.0"
|
version "0.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
|
||||||
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
|
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
|
||||||
|
@ -8670,9 +8675,9 @@ decimal.js@^10.3.1, decimal.js@^10.4.2:
|
||||||
integrity sha512-ic1yEvwT6GuvaYwBLLY6/aFFgjZdySKTE8en/fkU3QICTmRtgtSlFn0u0BXN06InZwtfCelR7j8LRiDI/02iGA==
|
integrity sha512-ic1yEvwT6GuvaYwBLLY6/aFFgjZdySKTE8en/fkU3QICTmRtgtSlFn0u0BXN06InZwtfCelR7j8LRiDI/02iGA==
|
||||||
|
|
||||||
decode-uri-component@^0.2.0:
|
decode-uri-component@^0.2.0:
|
||||||
version "0.2.0"
|
version "0.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
|
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9"
|
||||||
integrity sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==
|
integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==
|
||||||
|
|
||||||
decompress-response@^6.0.0:
|
decompress-response@^6.0.0:
|
||||||
version "6.0.0"
|
version "6.0.0"
|
||||||
|
@ -13102,6 +13107,16 @@ jsonwebtoken@^8.5.1:
|
||||||
ms "^2.1.1"
|
ms "^2.1.1"
|
||||||
semver "^5.6.0"
|
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:
|
jsprim@^2.0.2:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-2.0.2.tgz#77ca23dbcd4135cd364800d22ff82c2185803d4d"
|
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==
|
integrity sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==
|
||||||
|
|
||||||
loader-utils@^1.2.3, loader-utils@^1.4.0:
|
loader-utils@^1.2.3, loader-utils@^1.4.0:
|
||||||
version "1.4.0"
|
version "1.4.2"
|
||||||
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613"
|
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.2.tgz#29a957f3a63973883eb684f10ffd3d151fec01a3"
|
||||||
integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==
|
integrity sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==
|
||||||
dependencies:
|
dependencies:
|
||||||
big.js "^5.2.2"
|
big.js "^5.2.2"
|
||||||
emojis-list "^3.0.0"
|
emojis-list "^3.0.0"
|
||||||
json5 "^1.0.1"
|
json5 "^1.0.1"
|
||||||
|
|
||||||
loader-utils@^2.0.0:
|
loader-utils@^2.0.0, loader-utils@^2.0.3, loader-utils@^2.0.4:
|
||||||
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:
|
|
||||||
version "2.0.4"
|
version "2.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c"
|
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c"
|
||||||
integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==
|
integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==
|
||||||
|
@ -13441,9 +13447,9 @@ loader-utils@^2.0.3, loader-utils@^2.0.4:
|
||||||
json5 "^2.1.2"
|
json5 "^2.1.2"
|
||||||
|
|
||||||
loader-utils@^3.2.0:
|
loader-utils@^3.2.0:
|
||||||
version "3.2.0"
|
version "3.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-3.2.0.tgz#bcecc51a7898bee7473d4bc6b845b23af8304d4f"
|
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-3.2.1.tgz#4fb104b599daafd82ef3e1a41fb9265f87e1f576"
|
||||||
integrity sha512-HVl9ZqccQihZ7JM85dco1MvO9G+ONvxoGa9rkhzFsneGLKSUg1gJf9bWzhRhcvm2qChhWpebQhP44qxjKIUCaQ==
|
integrity sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==
|
||||||
|
|
||||||
locate-path@^2.0.0:
|
locate-path@^2.0.0:
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.6.0.tgz#6a44ff5e65113cb29252eadad1b8306f5fe0c626"
|
||||||
integrity sha512-aJm72XGWV+wJTKiqHmAaTNC/JQZV/Drv6A1kd1VQlzhzAXLqtBRBeTt3iTESImGe5TaBDHUOUeaGNw4v+7bqDw==
|
integrity sha512-aJm72XGWV+wJTKiqHmAaTNC/JQZV/Drv6A1kd1VQlzhzAXLqtBRBeTt3iTESImGe5TaBDHUOUeaGNw4v+7bqDw==
|
||||||
|
|
||||||
|
react-script-hook@^1.7.2:
|
||||||
|
version "1.7.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.7.2.tgz#ec130d67f9a25fcde57fbfd1faa87e5b97521948"
|
||||||
|
integrity sha512-fhyCEfXb94fag34UPRF0zry1XGwmVY+79iibWwTqAoOiCzYJQOYTiWJ7CnqglA9tMSV8g45cQpHCMcBwr7dwhA==
|
||||||
|
|
||||||
react-shallow-renderer@^16.15.0:
|
react-shallow-renderer@^16.15.0:
|
||||||
version "16.15.0"
|
version "16.15.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz#48fb2cf9b23d23cde96708fe5273a7d3446f4457"
|
resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz#48fb2cf9b23d23cde96708fe5273a7d3446f4457"
|
||||||
|
@ -17431,10 +17442,10 @@ semver@7.3.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
lru-cache "^6.0.0"
|
lru-cache "^6.0.0"
|
||||||
|
|
||||||
semver@7.x:
|
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.3.5"
|
version "7.5.4"
|
||||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
|
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
|
||||||
integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
|
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
|
||||||
dependencies:
|
dependencies:
|
||||||
lru-cache "^6.0.0"
|
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"
|
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
|
||||||
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
|
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:
|
send@0.17.2, send@latest:
|
||||||
version "0.17.2"
|
version "0.17.2"
|
||||||
resolved "https://registry.yarnpkg.com/send/-/send-0.17.2.tgz#926622f76601c41808012c8bf1688fe3906f7820"
|
resolved "https://registry.yarnpkg.com/send/-/send-0.17.2.tgz#926622f76601c41808012c8bf1688fe3906f7820"
|
||||||
|
@ -18532,6 +18536,13 @@ telejson@^6.0.8:
|
||||||
lodash "^4.17.21"
|
lodash "^4.17.21"
|
||||||
memoizerific "^1.11.3"
|
memoizerific "^1.11.3"
|
||||||
|
|
||||||
|
teller-connect-react@^0.1.0:
|
||||||
|
version "0.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/teller-connect-react/-/teller-connect-react-0.1.0.tgz#b3bae24f4410d622eb8c88c7668adb003eb7bfd7"
|
||||||
|
integrity sha512-ZI+OULCsuo/v1qetpjepOgM7TyIzwnMVE/54IruOPguQtJ/Ui3C1ax3wUb65AKZDyVQ7ZyjA+8ypT/yMYD9bIQ==
|
||||||
|
dependencies:
|
||||||
|
react-script-hook "^1.7.2"
|
||||||
|
|
||||||
terminal-link@^2.0.0:
|
terminal-link@^2.0.0:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994"
|
resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue