mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-08 15:05:22 +02:00
Merge pull request #119 from tmyracle/teller-p3-return-of-the-data
This commit is contained in:
commit
2eed43f6e0
36 changed files with 1133 additions and 106 deletions
|
@ -35,7 +35,8 @@ NX_POLYGON_API_KEY=
|
|||
# We use Teller.io for automated banking data. You can sign up for a free
|
||||
# account and get a free API key at https://teller.io
|
||||
NX_TELLER_SIGNING_SECRET=
|
||||
NX_TELLER_APP_ID=
|
||||
NEXT_PUBLIC_TELLER_APP_ID=
|
||||
NEXT_PUBLIC_TELLER_ENV=sandbox
|
||||
NX_TELLER_ENV=sandbox
|
||||
|
||||
########################################################################
|
||||
|
@ -57,4 +58,4 @@ NX_POSTMARK_API_TOKEN=
|
|||
########################################################################
|
||||
NX_PLAID_SECRET=
|
||||
NX_FINICITY_APP_KEY=
|
||||
NX_FINICITY_PARTNER_SECRET=
|
||||
NX_FINICITY_PARTNER_SECRET=
|
||||
|
|
|
@ -53,6 +53,14 @@ Then, create a new secret using `openssl rand -base64 32` and populate `NEXTAUTH
|
|||
|
||||
To enable transactional emails, you'll need to create a [Postmark](https://postmarkapp.com/) account and add your API key to your `.env` file (`NX_POSTMARK_API_TOKEN`). You can also set the from and reply-to email addresses (`NX_POSTMARK_FROM_ADDRESS` and `NX_POSTMARK_REPLY_TO_ADDRESS`). If you want to run the app without email, you can set `NX_POSTMARK_API_TOKEN` to a dummy value.
|
||||
|
||||
Maybe uses [Teller](https://teller.io/) for connecting financial accounts. To get started with Teller, you'll need to create an account. Once you've created an account:
|
||||
|
||||
- Add your Teller application id to your `.env` file (`NEXT_PUBLIC_TELLER_APP_ID`).
|
||||
- Download your authentication certificates from Teller, create a `certs` folder in the root of the project, and place your certs in that directory. You should have both a `certificate.pem` and `private_key.pem`. **NEVER** check these files into source control, the `.gitignore` file will prevent the `certs/` directory from being added, but please double check.
|
||||
- Set your `NEXT_PUBLIC_TELLER_ENV` and `NX_TELLER_ENV` to your desired environment. The default is `sandbox` which allows for testing with mock data. The login credentials for the sandbox environment are `username` and `password`. To connect to real financial accounts, you'll need to use the `development` environment.
|
||||
- Webhooks are not implemented yet, but you can populate the `NX_TELLER_SIGNING_SECRET` with the value from your Teller account.
|
||||
- We highly recommend checking out the [Teller docs](https://teller.io/docs) for more info.
|
||||
|
||||
Then run the following yarn commands:
|
||||
|
||||
```
|
||||
|
|
|
@ -36,6 +36,7 @@ import {
|
|||
valuationsRouter,
|
||||
institutionsRouter,
|
||||
finicityRouter,
|
||||
tellerRouter,
|
||||
transactionsRouter,
|
||||
holdingsRouter,
|
||||
securitiesRouter,
|
||||
|
@ -156,6 +157,7 @@ app.use('/v1/users', usersRouter)
|
|||
app.use('/v1/e2e', e2eRouter)
|
||||
app.use('/v1/plaid', plaidRouter)
|
||||
app.use('/v1/finicity', finicityRouter)
|
||||
app.use('/v1/teller', tellerRouter)
|
||||
app.use('/v1/accounts', accountsRouter)
|
||||
app.use('/v1/account-rollup', accountRollupRouter)
|
||||
app.use('/v1/connections', connectionsRouter)
|
||||
|
|
|
@ -240,6 +240,7 @@ const userService = new UserService(
|
|||
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||
PLAID: plaidService,
|
||||
FINICITY: finicityService,
|
||||
TELLER: tellerService,
|
||||
})
|
||||
|
||||
const institutionService: IInstitutionService = new InstitutionService(
|
||||
|
|
|
@ -5,6 +5,7 @@ export { default as usersRouter } from './users.router'
|
|||
export { default as webhooksRouter } from './webhooks.router'
|
||||
export { default as plaidRouter } from './plaid.router'
|
||||
export { default as finicityRouter } from './finicity.router'
|
||||
export { default as tellerRouter } from './teller.router'
|
||||
export { default as valuationsRouter } from './valuations.router'
|
||||
export { default as institutionsRouter } from './institutions.router'
|
||||
export { default as transactionsRouter } from './transactions.router'
|
||||
|
|
45
apps/server/src/app/routes/teller.router.ts
Normal file
45
apps/server/src/app/routes/teller.router.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import endpoint from '../lib/endpoint'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post(
|
||||
'/handle-enrollment',
|
||||
endpoint.create({
|
||||
input: z.object({
|
||||
institution: z.object({
|
||||
name: z.string(),
|
||||
id: z.string(),
|
||||
}),
|
||||
enrollment: z.object({
|
||||
accessToken: z.string(),
|
||||
user: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
enrollment: z.object({
|
||||
id: z.string(),
|
||||
institution: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
}),
|
||||
signatures: z.array(z.string()).optional(),
|
||||
}),
|
||||
}),
|
||||
resolve: ({ input: { institution, enrollment }, ctx }) => {
|
||||
return ctx.tellerService.handleEnrollment(ctx.user!.id, institution, enrollment)
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/institutions/sync',
|
||||
endpoint.create({
|
||||
resolve: async ({ ctx }) => {
|
||||
ctx.ability.throwUnlessCan('manage', 'Institution')
|
||||
await ctx.queueService.getQueue('sync-institution').add('sync-teller-institutions', {})
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
|
@ -1,22 +1,28 @@
|
|||
import type { PrismaClient, User } from '@prisma/client'
|
||||
import { faker } from '@faker-js/faker'
|
||||
|
||||
export async function resetUser(prisma: PrismaClient, authId = 'TODO'): Promise<User> {
|
||||
// eslint-disable-next-line
|
||||
const [_, __, ___, user] = await prisma.$transaction([
|
||||
prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${authId};`,
|
||||
export async function resetUser(prisma: PrismaClient, authId = '__TEST_USER_ID__'): Promise<User> {
|
||||
try {
|
||||
// eslint-disable-next-line
|
||||
const [_, __, ___, user] = await prisma.$transaction([
|
||||
prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${authId};`,
|
||||
|
||||
// Deleting a user does not cascade to securities, so delete all security records
|
||||
prisma.$executeRaw`DELETE from security;`,
|
||||
prisma.$executeRaw`DELETE from security_pricing;`,
|
||||
// Deleting a user does not cascade to securities, so delete all security records
|
||||
prisma.$executeRaw`DELETE from security;`,
|
||||
prisma.$executeRaw`DELETE from security_pricing;`,
|
||||
|
||||
prisma.user.create({
|
||||
data: {
|
||||
authId,
|
||||
email: 'test@example.com',
|
||||
finicityCustomerId: 'TEST',
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return user
|
||||
prisma.user.create({
|
||||
data: {
|
||||
authId,
|
||||
email: faker.internet.email(),
|
||||
finicityCustomerId: faker.string.uuid(),
|
||||
tellerUserId: faker.string.uuid(),
|
||||
},
|
||||
}),
|
||||
])
|
||||
return user
|
||||
} catch (e) {
|
||||
console.error('error in reset user transaction', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
|
118
apps/workers/src/app/__tests__/teller.integration.spec.ts
Normal file
118
apps/workers/src/app/__tests__/teller.integration.spec.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
import type { User } from '@prisma/client'
|
||||
import { TellerGenerator } from '../../../../../tools/generators'
|
||||
import { TellerApi } from '@maybe-finance/teller-api'
|
||||
jest.mock('@maybe-finance/teller-api')
|
||||
import {
|
||||
TellerETL,
|
||||
TellerService,
|
||||
type IAccountConnectionProvider,
|
||||
} from '@maybe-finance/server/features'
|
||||
import { createLogger } from '@maybe-finance/server/shared'
|
||||
import prisma from '../lib/prisma'
|
||||
import { resetUser } from './helpers/user.test-helper'
|
||||
import { transports } from 'winston'
|
||||
import { cryptoService } from '../lib/di'
|
||||
|
||||
const logger = createLogger({ level: 'debug', transports: [new transports.Console()] })
|
||||
const teller = jest.mocked(new TellerApi())
|
||||
const tellerETL = new TellerETL(logger, prisma, teller, cryptoService)
|
||||
const service: IAccountConnectionProvider = new TellerService(
|
||||
logger,
|
||||
prisma,
|
||||
teller,
|
||||
tellerETL,
|
||||
cryptoService,
|
||||
'TELLER_WEBHOOK_URL',
|
||||
true
|
||||
)
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
|
||||
describe('Teller', () => {
|
||||
let user: User
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
user = await resetUser(prisma)
|
||||
})
|
||||
|
||||
it('syncs connection', async () => {
|
||||
const tellerConnection = TellerGenerator.generateConnection()
|
||||
const tellerAccounts = tellerConnection.accountsWithBalances
|
||||
const tellerTransactions = tellerConnection.transactions
|
||||
|
||||
teller.getAccounts.mockResolvedValue(tellerAccounts)
|
||||
|
||||
teller.getTransactions.mockImplementation(async ({ accountId }) => {
|
||||
return Promise.resolve(tellerTransactions.filter((t) => t.account_id === accountId))
|
||||
})
|
||||
|
||||
const connection = await prisma.accountConnection.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: 'TEST_TELLER',
|
||||
type: 'teller',
|
||||
tellerEnrollmentId: tellerConnection.enrollment.enrollment.id,
|
||||
tellerInstitutionId: tellerConnection.enrollment.institutionId,
|
||||
tellerAccessToken: cryptoService.encrypt(tellerConnection.enrollment.accessToken),
|
||||
},
|
||||
})
|
||||
|
||||
await service.sync(connection)
|
||||
|
||||
const { accounts } = await prisma.accountConnection.findUniqueOrThrow({
|
||||
where: {
|
||||
id: connection.id,
|
||||
},
|
||||
include: {
|
||||
accounts: {
|
||||
include: {
|
||||
transactions: true,
|
||||
investmentTransactions: true,
|
||||
holdings: true,
|
||||
valuations: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// all accounts
|
||||
expect(accounts).toHaveLength(tellerConnection.accounts.length)
|
||||
for (const account of accounts) {
|
||||
expect(account.transactions).toHaveLength(
|
||||
tellerTransactions.filter((t) => t.account_id === account.tellerAccountId).length
|
||||
)
|
||||
}
|
||||
|
||||
// credit accounts
|
||||
const creditAccounts = tellerAccounts.filter((a) => a.type === 'credit')
|
||||
expect(accounts.filter((a) => a.type === 'CREDIT')).toHaveLength(creditAccounts.length)
|
||||
for (const creditAccount of creditAccounts) {
|
||||
const account = accounts.find((a) => a.tellerAccountId === creditAccount.id)!
|
||||
expect(account.transactions).toHaveLength(
|
||||
tellerTransactions.filter((t) => t.account_id === account.tellerAccountId).length
|
||||
)
|
||||
expect(account.holdings).toHaveLength(0)
|
||||
expect(account.valuations).toHaveLength(0)
|
||||
expect(account.investmentTransactions).toHaveLength(0)
|
||||
}
|
||||
|
||||
// depository accounts
|
||||
const depositoryAccounts = tellerAccounts.filter((a) => a.type === 'depository')
|
||||
expect(accounts.filter((a) => a.type === 'DEPOSITORY')).toHaveLength(
|
||||
depositoryAccounts.length
|
||||
)
|
||||
for (const depositoryAccount of depositoryAccounts) {
|
||||
const account = accounts.find((a) => a.tellerAccountId === depositoryAccount.id)!
|
||||
expect(account.transactions).toHaveLength(
|
||||
tellerTransactions.filter((t) => t.account_id === account.tellerAccountId).length
|
||||
)
|
||||
expect(account.holdings).toHaveLength(0)
|
||||
expect(account.valuations).toHaveLength(0)
|
||||
expect(account.investmentTransactions).toHaveLength(0)
|
||||
}
|
||||
})
|
||||
})
|
|
@ -259,6 +259,7 @@ export const securityPricingProcessor: ISecurityPricingProcessor = new SecurityP
|
|||
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||
PLAID: plaidService,
|
||||
FINICITY: finicityService,
|
||||
TELLER: tellerService,
|
||||
})
|
||||
|
||||
export const institutionService: IInstitutionService = new InstitutionService(
|
||||
|
|
|
@ -115,6 +115,11 @@ syncInstitutionQueue.process(
|
|||
async () => await institutionService.sync('FINICITY')
|
||||
)
|
||||
|
||||
syncInstitutionQueue.process(
|
||||
'sync-teller-institutions',
|
||||
async () => await institutionService.sync('TELLER')
|
||||
)
|
||||
|
||||
syncInstitutionQueue.add(
|
||||
'sync-plaid-institutions',
|
||||
{},
|
||||
|
@ -131,6 +136,14 @@ syncInstitutionQueue.add(
|
|||
}
|
||||
)
|
||||
|
||||
syncInstitutionQueue.add(
|
||||
'sync-teller-institutions',
|
||||
{},
|
||||
{
|
||||
repeat: { cron: '0 0 */1 * *' }, // Run every 24 hours
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* send-email queue
|
||||
*/
|
||||
|
|
|
@ -7,12 +7,14 @@ import {
|
|||
useDebounce,
|
||||
usePlaid,
|
||||
useFinicity,
|
||||
useTellerConfig,
|
||||
useTellerConnect,
|
||||
} from '@maybe-finance/client/shared'
|
||||
|
||||
import { Input } from '@maybe-finance/design-system'
|
||||
import InstitutionGrid from './InstitutionGrid'
|
||||
import { AccountTypeGrid } from './AccountTypeGrid'
|
||||
import InstitutionList, { MIN_QUERY_LENGTH } from './InstitutionList'
|
||||
import { useLogger } from '@maybe-finance/client/shared'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
|
@ -23,6 +25,7 @@ export default function AccountTypeSelector({
|
|||
view: string
|
||||
onViewChange: (view: string) => void
|
||||
}) {
|
||||
const logger = useLogger()
|
||||
const { setAccountManager } = useAccountContext()
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
|
@ -33,8 +36,11 @@ export default function AccountTypeSelector({
|
|||
debouncedSearchQuery.length >= MIN_QUERY_LENGTH &&
|
||||
view !== 'manual'
|
||||
|
||||
const config = useTellerConfig(logger)
|
||||
|
||||
const { openPlaid } = usePlaid()
|
||||
const { openFinicity } = useFinicity()
|
||||
const { open: openTeller } = useTellerConnect(config, logger)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
|
@ -77,6 +83,9 @@ export default function AccountTypeSelector({
|
|||
case 'FINICITY':
|
||||
openFinicity(providerInstitution.providerId)
|
||||
break
|
||||
case 'TELLER':
|
||||
openTeller(providerInstitution.providerId)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
@ -138,11 +147,12 @@ export default function AccountTypeSelector({
|
|||
categoryUser: 'crypto',
|
||||
},
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!data) return
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (data.provider) {
|
||||
case 'PLAID':
|
||||
|
@ -151,6 +161,9 @@ export default function AccountTypeSelector({
|
|||
case 'FINICITY':
|
||||
openFinicity(data.providerId)
|
||||
break
|
||||
case 'TELLER':
|
||||
openTeller(data.providerId)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
|
@ -14,48 +14,48 @@ const banks: GridImage[] = [
|
|||
src: 'chase-bank.png',
|
||||
alt: 'Chase Bank',
|
||||
institution: {
|
||||
provider: 'PLAID',
|
||||
providerId: 'ins_56',
|
||||
provider: 'TELLER',
|
||||
providerId: 'chase',
|
||||
},
|
||||
},
|
||||
{
|
||||
src: 'capital-one.png',
|
||||
alt: 'Capital One Bank',
|
||||
institution: {
|
||||
provider: 'PLAID',
|
||||
providerId: 'ins_128026',
|
||||
provider: 'TELLER',
|
||||
providerId: 'capital_one',
|
||||
},
|
||||
},
|
||||
{
|
||||
src: 'wells-fargo.png',
|
||||
alt: 'Wells Fargo Bank',
|
||||
institution: {
|
||||
provider: 'PLAID',
|
||||
providerId: 'ins_127991',
|
||||
provider: 'TELLER',
|
||||
providerId: 'wells_fargo',
|
||||
},
|
||||
},
|
||||
{
|
||||
src: 'american-express.png',
|
||||
alt: 'American Express Bank',
|
||||
institution: {
|
||||
provider: 'PLAID',
|
||||
providerId: 'ins_10',
|
||||
provider: 'TELLER',
|
||||
providerId: 'amex',
|
||||
},
|
||||
},
|
||||
{
|
||||
src: 'bofa.png',
|
||||
alt: 'Bank of America',
|
||||
institution: {
|
||||
provider: 'PLAID',
|
||||
providerId: 'ins_127989',
|
||||
provider: 'TELLER',
|
||||
providerId: 'bank_of_america',
|
||||
},
|
||||
},
|
||||
{
|
||||
src: 'usaa-bank.png',
|
||||
alt: 'USAA Bank',
|
||||
institution: {
|
||||
provider: 'PLAID',
|
||||
providerId: 'ins_7',
|
||||
provider: 'TELLER',
|
||||
providerId: 'usaa',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
|
@ -5,6 +5,7 @@ export * from './useFinicityApi'
|
|||
export * from './useInstitutionApi'
|
||||
export * from './useUserApi'
|
||||
export * from './usePlaidApi'
|
||||
export * from './useTellerApi'
|
||||
export * from './useValuationApi'
|
||||
export * from './useTransactionApi'
|
||||
export * from './useHoldingApi'
|
||||
|
|
64
libs/client/shared/src/api/useTellerApi.ts
Normal file
64
libs/client/shared/src/api/useTellerApi.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { useMemo } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { SharedType } from '@maybe-finance/shared'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import type { TellerTypes } from '@maybe-finance/teller-api'
|
||||
import { useAccountConnectionApi } from './useAccountConnectionApi'
|
||||
|
||||
type TellerInstitution = {
|
||||
name: string
|
||||
id: string
|
||||
}
|
||||
|
||||
const TellerApi = (axios: AxiosInstance) => ({
|
||||
async handleEnrollment(input: {
|
||||
institution: TellerInstitution
|
||||
enrollment: TellerTypes.Enrollment
|
||||
}) {
|
||||
const { data } = await axios.post<SharedType.AccountConnection>(
|
||||
'/teller/handle-enrollment',
|
||||
input
|
||||
)
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
export function useTellerApi() {
|
||||
const queryClient = useQueryClient()
|
||||
const { axios } = useAxiosWithAuth()
|
||||
const api = useMemo(() => TellerApi(axios), [axios])
|
||||
|
||||
const { useSyncConnection } = useAccountConnectionApi()
|
||||
const syncConnection = useSyncConnection()
|
||||
|
||||
const addConnectionToState = (connection: SharedType.AccountConnection) => {
|
||||
const accountsData = queryClient.getQueryData<SharedType.AccountsResponse>(['accounts'])
|
||||
if (!accountsData)
|
||||
queryClient.setQueryData<SharedType.AccountsResponse>(['accounts'], {
|
||||
connections: [{ ...connection, accounts: [] }],
|
||||
accounts: [],
|
||||
})
|
||||
else {
|
||||
const { connections, ...rest } = accountsData
|
||||
queryClient.setQueryData<SharedType.AccountsResponse>(['accounts'], {
|
||||
connections: [...connections, { ...connection, accounts: [] }],
|
||||
...rest,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const useHandleEnrollment = () =>
|
||||
useMutation(api.handleEnrollment, {
|
||||
onSuccess: (_connection) => {
|
||||
addConnectionToState(_connection)
|
||||
syncConnection.mutate(_connection.id)
|
||||
toast.success(`Account connection added!`)
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
useHandleEnrollment,
|
||||
}
|
||||
}
|
|
@ -9,5 +9,6 @@ export * from './useQueryParam'
|
|||
export * from './useScreenSize'
|
||||
export * from './useAccountNotifications'
|
||||
export * from './usePlaid'
|
||||
export * from './useTeller'
|
||||
export * from './useProviderStatus'
|
||||
export * from './useModalManager'
|
||||
|
|
182
libs/client/shared/src/hooks/useTeller.ts
Normal file
182
libs/client/shared/src/hooks/useTeller.ts
Normal file
|
@ -0,0 +1,182 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import * as Sentry from '@sentry/react'
|
||||
import type { Logger } from '../providers/LogProvider'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useAccountContext } from '../providers'
|
||||
import { useTellerApi } from '../api'
|
||||
import type {
|
||||
TellerConnectEnrollment,
|
||||
TellerConnectFailure,
|
||||
TellerConnectOptions,
|
||||
TellerConnectInstance,
|
||||
} from 'teller-connect-react'
|
||||
import useScript from 'react-script-hook'
|
||||
type TellerEnvironment = 'sandbox' | 'development' | 'production' | undefined
|
||||
type TellerAccountSelection = 'disabled' | 'single' | 'multiple' | undefined
|
||||
const TC_JS = 'https://cdn.teller.io/connect/connect.js'
|
||||
|
||||
// Create the base configuration for Teller Connect
|
||||
export const useTellerConfig = (logger: Logger) => {
|
||||
return {
|
||||
applicationId: process.env.NEXT_PUBLIC_TELLER_APP_ID ?? 'ADD_TELLER_APP_ID',
|
||||
environment: (process.env.NEXT_PUBLIC_TELLER_ENV as TellerEnvironment) ?? 'sandbox',
|
||||
selectAccount: 'disabled' as TellerAccountSelection,
|
||||
onInit: () => {
|
||||
logger.debug(`Teller Connect has initialized`)
|
||||
},
|
||||
onSuccess: {},
|
||||
onExit: () => {
|
||||
logger.debug(`Teller Connect exited`)
|
||||
},
|
||||
onFailure: (failure: TellerConnectFailure) => {
|
||||
logger.error(`Teller Connect exited with error`, failure)
|
||||
Sentry.captureEvent({
|
||||
level: 'error',
|
||||
message: 'TELLER_CONNECT_ERROR',
|
||||
tags: {
|
||||
'teller.error.code': failure.code,
|
||||
'teller.error.message': failure.message,
|
||||
},
|
||||
})
|
||||
},
|
||||
} as TellerConnectOptions
|
||||
}
|
||||
|
||||
// Custom implementation of useTellerHook to handle institution id being passed in
|
||||
export const useTellerConnect = (options: TellerConnectOptions, logger: Logger) => {
|
||||
const { useHandleEnrollment } = useTellerApi()
|
||||
const handleEnrollment = useHandleEnrollment()
|
||||
const { setAccountManager } = useAccountContext()
|
||||
const [loading, error] = useScript({
|
||||
src: TC_JS,
|
||||
checkForExisting: true,
|
||||
})
|
||||
|
||||
const [teller, setTeller] = useState<TellerConnectInstance | null>(null)
|
||||
const [iframeLoaded, setIframeLoaded] = useState(false)
|
||||
|
||||
const createTellerInstance = (institutionId: string) => {
|
||||
return createTeller(
|
||||
{
|
||||
...options,
|
||||
onSuccess: async (enrollment: TellerConnectEnrollment) => {
|
||||
logger.debug('User enrolled successfully')
|
||||
try {
|
||||
await handleEnrollment.mutateAsync({
|
||||
institution: {
|
||||
id: institutionId!,
|
||||
name: enrollment.enrollment.institution.name,
|
||||
},
|
||||
enrollment,
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(`Failed to add account`)
|
||||
}
|
||||
},
|
||||
institution: institutionId,
|
||||
onInit: () => {
|
||||
setIframeLoaded(true)
|
||||
options.onInit && options.onInit()
|
||||
},
|
||||
},
|
||||
window.TellerConnect.setup
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!options.applicationId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (error || !window.TellerConnect) {
|
||||
console.error('Error loading TellerConnect:', error)
|
||||
return
|
||||
}
|
||||
|
||||
if (teller != null) {
|
||||
teller.destroy()
|
||||
}
|
||||
|
||||
return () => teller?.destroy()
|
||||
}, [
|
||||
loading,
|
||||
error,
|
||||
options.applicationId,
|
||||
options.enrollmentId,
|
||||
options.connectToken,
|
||||
options.products,
|
||||
])
|
||||
|
||||
const ready = teller != null && (!loading || iframeLoaded)
|
||||
|
||||
const logIt = () => {
|
||||
if (!options.applicationId) {
|
||||
console.error('teller-connect-react: open() called without a valid applicationId.')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error,
|
||||
ready,
|
||||
open: (institutionId: string) => {
|
||||
logIt()
|
||||
const tellerInstance = createTellerInstance(institutionId)
|
||||
tellerInstance.open()
|
||||
setAccountManager({ view: 'idle' })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface ManagerState {
|
||||
teller: TellerConnectInstance | null
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export const createTeller = (
|
||||
config: TellerConnectOptions,
|
||||
creator: (config: TellerConnectOptions) => TellerConnectInstance
|
||||
) => {
|
||||
const state: ManagerState = {
|
||||
teller: null,
|
||||
open: false,
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined' || !window.TellerConnect) {
|
||||
throw new Error('TellerConnect is not loaded')
|
||||
}
|
||||
|
||||
state.teller = creator({
|
||||
...config,
|
||||
onExit: () => {
|
||||
state.open = false
|
||||
config.onExit && config.onExit()
|
||||
},
|
||||
})
|
||||
|
||||
const open = () => {
|
||||
if (!state.teller) {
|
||||
return
|
||||
}
|
||||
|
||||
state.open = true
|
||||
state.teller.open()
|
||||
}
|
||||
|
||||
const destroy = () => {
|
||||
if (!state.teller) {
|
||||
return
|
||||
}
|
||||
|
||||
state.teller.destroy()
|
||||
state.teller = null
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
destroy,
|
||||
}
|
||||
}
|
|
@ -49,6 +49,7 @@ type AccountManager =
|
|||
| { view: 'idle' }
|
||||
| { view: 'add-plaid'; linkToken: string }
|
||||
| { view: 'add-finicity' }
|
||||
| { view: 'add-teller' }
|
||||
| { view: 'add-account' }
|
||||
| { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> }
|
||||
| { view: 'add-vehicle'; defaultValues: Partial<CreateVehicleFields> }
|
||||
|
|
|
@ -271,7 +271,7 @@ export class InstitutionService implements IInstitutionService {
|
|||
provider_institution pi
|
||||
SET
|
||||
institution_id = i.id,
|
||||
rank = (CASE WHEN pi.provider = 'PLAID' THEN 1 ELSE 0 END)
|
||||
rank = (CASE WHEN pi.provider = 'TELLER' THEN 1 ELSE 0 END)
|
||||
FROM
|
||||
duplicates d
|
||||
INNER JOIN institutions i ON i.name = d.name AND i.url = d.url
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { AccountConnection, PrismaClient } from '@prisma/client'
|
||||
import type { Logger } from 'winston'
|
||||
import { SharedUtil, AccountUtil, type SharedType } from '@maybe-finance/shared'
|
||||
import { AccountUtil, SharedUtil, type SharedType } from '@maybe-finance/shared'
|
||||
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
|
||||
import { DbUtil, TellerUtil, type IETL, type ICryptoService } from '@maybe-finance/server/shared'
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
@ -101,16 +101,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
|||
|
||||
private async _extractAccounts(accessToken: string) {
|
||||
const accounts = await this.teller.getAccounts({ accessToken })
|
||||
const accountsWithBalances = await Promise.all(
|
||||
accounts.map(async (a) => {
|
||||
const balance = await this.teller.getAccountBalances({
|
||||
accountId: a.id,
|
||||
accessToken,
|
||||
})
|
||||
return { ...a, balance }
|
||||
})
|
||||
)
|
||||
return accountsWithBalances
|
||||
return accounts
|
||||
}
|
||||
|
||||
private _loadAccounts(connection: Connection, { accounts }: Pick<TellerData, 'accounts'>) {
|
||||
|
@ -119,6 +110,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
|||
...accounts.map((tellerAccount) => {
|
||||
const type = TellerUtil.getType(tellerAccount.type)
|
||||
const classification = AccountUtil.getClassification(type)
|
||||
|
||||
return this.prisma.account.upsert({
|
||||
where: {
|
||||
accountConnectionId_tellerAccountId: {
|
||||
|
@ -132,6 +124,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
|||
categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type),
|
||||
subcategoryProvider: tellerAccount.subtype ?? 'other',
|
||||
accountConnectionId: connection.id,
|
||||
userId: connection.userId,
|
||||
tellerAccountId: tellerAccount.id,
|
||||
name: tellerAccount.name,
|
||||
tellerType: tellerAccount.type,
|
||||
|
@ -210,7 +203,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
|||
${Prisma.join(
|
||||
chunk.map((tellerTransaction) => {
|
||||
const {
|
||||
id,
|
||||
id: transactionId,
|
||||
account_id,
|
||||
description,
|
||||
amount,
|
||||
|
@ -224,15 +217,15 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
|||
(SELECT id FROM account WHERE account_connection_id = ${
|
||||
connection.id
|
||||
} AND teller_account_id = ${account_id.toString()}),
|
||||
${id},
|
||||
${transactionId},
|
||||
${date}::date,
|
||||
${[description].filter(Boolean).join(' ')},
|
||||
${description},
|
||||
${DbUtil.toDecimal(-amount)},
|
||||
${status === 'pending'},
|
||||
${'USD'},
|
||||
${details.counterparty.name ?? ''},
|
||||
${type},
|
||||
${details.category ?? ''},
|
||||
${details.category ?? ''}
|
||||
)`
|
||||
})
|
||||
)}
|
||||
|
|
|
@ -6,19 +6,11 @@ import type {
|
|||
IAccountConnectionProvider,
|
||||
} from '../../account-connection'
|
||||
import { SharedUtil } from '@maybe-finance/shared'
|
||||
import type { SharedType } from '@maybe-finance/shared'
|
||||
import type { SyncConnectionOptions, CryptoService, IETL } from '@maybe-finance/server/shared'
|
||||
import _ from 'lodash'
|
||||
import { ErrorUtil, etl } from '@maybe-finance/server/shared'
|
||||
import type { TellerApi } from '@maybe-finance/teller-api'
|
||||
|
||||
export interface ITellerConnect {
|
||||
generateConnectUrl(userId: User['id'], institutionId: string): Promise<{ link: string }>
|
||||
|
||||
generateFixConnectUrl(
|
||||
userId: User['id'],
|
||||
accountConnectionId: AccountConnection['id']
|
||||
): Promise<{ link: string }>
|
||||
}
|
||||
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
|
||||
|
||||
export class TellerService implements IAccountConnectionProvider, IInstitutionProvider {
|
||||
constructor(
|
||||
|
@ -44,6 +36,7 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
|
|||
where: { id: connection.id },
|
||||
data: {
|
||||
status: 'OK',
|
||||
syncStatus: 'IDLE',
|
||||
},
|
||||
})
|
||||
break
|
||||
|
@ -67,19 +60,28 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
|
|||
|
||||
async delete(connection: AccountConnection) {
|
||||
// purge teller data
|
||||
if (connection.tellerAccessToken && connection.tellerAccountId) {
|
||||
await this.teller.deleteAccount({
|
||||
accessToken: this.crypto.decrypt(connection.tellerAccessToken),
|
||||
accountId: connection.tellerAccountId,
|
||||
if (connection.tellerAccessToken && connection.tellerEnrollmentId) {
|
||||
const accounts = await this.prisma.account.findMany({
|
||||
where: { accountConnectionId: connection.id },
|
||||
})
|
||||
|
||||
this.logger.info(`Item ${connection.tellerAccountId} removed`)
|
||||
for (const account of accounts) {
|
||||
if (!account.tellerAccountId) continue
|
||||
await this.teller.deleteAccount({
|
||||
accessToken: this.crypto.decrypt(connection.tellerAccessToken),
|
||||
accountId: account.tellerAccountId,
|
||||
})
|
||||
|
||||
this.logger.info(`Teller account ${account.id} removed`)
|
||||
}
|
||||
|
||||
this.logger.info(`Teller enrollment ${connection.tellerEnrollmentId} removed`)
|
||||
}
|
||||
}
|
||||
|
||||
async getInstitutions() {
|
||||
const tellerInstitutions = await SharedUtil.paginate({
|
||||
pageSize: 500,
|
||||
pageSize: 10000,
|
||||
delay:
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? {
|
||||
|
@ -87,20 +89,20 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
|
|||
milliseconds: 7_000, // Sandbox rate limited at 10 calls / minute
|
||||
}
|
||||
: undefined,
|
||||
fetchData: (offset, count) =>
|
||||
fetchData: () =>
|
||||
SharedUtil.withRetry(
|
||||
() =>
|
||||
this.teller.getInstitutions().then((data) => {
|
||||
this.logger.debug(
|
||||
`paginated teller fetch inst=${data.institutions.length} (total=${data.institutions.length} offset=${offset} count=${count})`
|
||||
`teller fetch inst=${data.length} (total=${data.length})`
|
||||
)
|
||||
return data.institutions
|
||||
return data
|
||||
}),
|
||||
{
|
||||
maxRetries: 3,
|
||||
onError: (error, attempt) => {
|
||||
this.logger.error(
|
||||
`Teller fetch institutions request failed attempt=${attempt} offset=${offset} count=${count}`,
|
||||
`Teller fetch institutions request failed attempt=${attempt}`,
|
||||
{ error: ErrorUtil.parseError(error) }
|
||||
)
|
||||
|
||||
|
@ -115,12 +117,57 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
|
|||
return {
|
||||
providerId: id,
|
||||
name,
|
||||
url: undefined,
|
||||
logo: `https://teller.io/images/banks/${id}.jpg}`,
|
||||
primaryColor: undefined,
|
||||
oauth: undefined,
|
||||
url: null,
|
||||
logo: null,
|
||||
logoUrl: `https://teller.io/images/banks/${id}.jpg`,
|
||||
primaryColor: null,
|
||||
oauth: false,
|
||||
data: tellerInstitution,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async handleEnrollment(
|
||||
userId: User['id'],
|
||||
institution: Pick<TellerTypes.Institution, 'name' | 'id'>,
|
||||
enrollment: TellerTypes.Enrollment
|
||||
) {
|
||||
const connections = await this.prisma.accountConnection.findMany({
|
||||
where: { userId },
|
||||
})
|
||||
|
||||
if (connections.length > 40) {
|
||||
throw new Error('MAX_ACCOUNT_CONNECTIONS')
|
||||
}
|
||||
|
||||
const accounts = await this.teller.getAccounts({ accessToken: enrollment.accessToken })
|
||||
|
||||
this.logger.info(`Teller accounts retrieved for enrollment ${enrollment.enrollment.id}`)
|
||||
|
||||
// If all the accounts are Non-USD, throw an error
|
||||
if (accounts.every((a) => a.currency !== 'USD')) {
|
||||
throw new Error('USD_ONLY')
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
tellerUserId: enrollment.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
const accountConnection = await this.prisma.accountConnection.create({
|
||||
data: {
|
||||
name: enrollment.enrollment.institution.name,
|
||||
type: 'teller' as SharedType.AccountConnectionType,
|
||||
tellerEnrollmentId: enrollment.enrollment.id,
|
||||
tellerInstitutionId: institution.id,
|
||||
tellerAccessToken: this.crypto.encrypt(enrollment.accessToken),
|
||||
userId,
|
||||
syncStatus: 'PENDING',
|
||||
},
|
||||
})
|
||||
|
||||
return accountConnection
|
||||
}
|
||||
}
|
||||
|
|
|
@ -70,7 +70,7 @@ export type SyncSecurityQueue = IQueue<SyncSecurityQueueJobData, 'sync-all-secur
|
|||
export type PurgeUserQueue = IQueue<{ userId: User['id'] }, 'purge-user'>
|
||||
export type SyncInstitutionQueue = IQueue<
|
||||
{},
|
||||
'sync-finicity-institutions' | 'sync-plaid-institutions'
|
||||
'sync-finicity-institutions' | 'sync-plaid-institutions' | 'sync-teller-institutions'
|
||||
>
|
||||
export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>
|
||||
|
||||
|
|
|
@ -2,8 +2,8 @@ import {
|
|||
Prisma,
|
||||
AccountCategory,
|
||||
AccountType,
|
||||
type AccountClassification,
|
||||
type Account,
|
||||
type AccountClassification,
|
||||
} from '@prisma/client'
|
||||
import type { TellerTypes } from '@maybe-finance/teller-api'
|
||||
import { Duration } from 'luxon'
|
||||
|
@ -14,7 +14,7 @@ import { Duration } from 'luxon'
|
|||
export const TELLER_WINDOW_MAX = Duration.fromObject({ years: 1 })
|
||||
|
||||
export function getAccountBalanceData(
|
||||
{ balances, currency }: Pick<TellerTypes.AccountWithBalances, 'balances' | 'currency'>,
|
||||
{ balance, currency }: Pick<TellerTypes.AccountWithBalances, 'balance' | 'currency'>,
|
||||
classification: AccountClassification
|
||||
): Pick<
|
||||
Account,
|
||||
|
@ -24,16 +24,14 @@ export function getAccountBalanceData(
|
|||
| 'availableBalanceStrategy'
|
||||
| 'currencyCode'
|
||||
> {
|
||||
// Flip balance values to positive for liabilities
|
||||
const sign = classification === 'liability' ? -1 : 1
|
||||
|
||||
return {
|
||||
currentBalanceProvider: new Prisma.Decimal(
|
||||
balances.ledger ? sign * Number(balances.ledger) : 0
|
||||
balance.ledger ? sign * Number(balance.ledger) : 0
|
||||
),
|
||||
currentBalanceStrategy: 'current',
|
||||
availableBalanceProvider: new Prisma.Decimal(
|
||||
balances.available ? sign * Number(balances.available) : 0
|
||||
balance.available ? sign * Number(balance.available) : 0
|
||||
),
|
||||
availableBalanceStrategy: 'available',
|
||||
currencyCode: currency,
|
||||
|
|
|
@ -34,7 +34,20 @@ export class TellerApi {
|
|||
*/
|
||||
|
||||
async getAccounts({ accessToken }: AuthenticatedRequest): Promise<GetAccountsResponse> {
|
||||
return this.get<GetAccountsResponse>(`/accounts`, accessToken)
|
||||
const accounts = await this.get<GetAccountsResponse>(`/accounts`, accessToken)
|
||||
const accountsWithBalances = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
const balance = await this.getAccountBalances({
|
||||
accountId: account.id,
|
||||
accessToken,
|
||||
})
|
||||
return {
|
||||
...account,
|
||||
balance,
|
||||
}
|
||||
})
|
||||
)
|
||||
return accountsWithBalances
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -137,12 +150,12 @@ export class TellerApi {
|
|||
}
|
||||
|
||||
private async getApi(accessToken: string): Promise<AxiosInstance> {
|
||||
const cert = fs.readFileSync('../../../certs/teller-certificate.pem', 'utf8')
|
||||
const key = fs.readFileSync('../../../certs/teller-private-key.pem', 'utf8')
|
||||
const cert = fs.readFileSync('./certs/certificate.pem')
|
||||
const key = fs.readFileSync('./certs/private_key.pem')
|
||||
|
||||
const agent = new https.Agent({
|
||||
cert,
|
||||
key,
|
||||
cert: cert,
|
||||
key: key,
|
||||
})
|
||||
|
||||
if (!this.api) {
|
||||
|
@ -153,16 +166,16 @@ export class TellerApi {
|
|||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
auth: {
|
||||
username: accessToken,
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
this.api.interceptors.request.use((config) => {
|
||||
// Add the access_token to the auth object
|
||||
config.auth = {
|
||||
username: 'ACCESS_TOKEN',
|
||||
password: accessToken,
|
||||
}
|
||||
return config
|
||||
})
|
||||
} else if (this.api.defaults.auth?.username !== accessToken) {
|
||||
this.api.defaults.auth = {
|
||||
username: accessToken,
|
||||
password: '',
|
||||
}
|
||||
}
|
||||
|
||||
return this.api
|
||||
|
|
|
@ -12,13 +12,15 @@ export enum AccountType {
|
|||
export type DepositorySubtypes =
|
||||
| 'checking'
|
||||
| 'savings'
|
||||
| 'money market'
|
||||
| 'certificate of deposit'
|
||||
| 'money_market'
|
||||
| 'certificate_of_deposit'
|
||||
| 'treasury'
|
||||
| 'sweep'
|
||||
|
||||
export type CreditSubtype = 'credit_card'
|
||||
|
||||
export type AccountStatus = 'open' | 'closed'
|
||||
|
||||
interface BaseAccount {
|
||||
enrollment_id: string
|
||||
links: {
|
||||
|
@ -34,7 +36,7 @@ interface BaseAccount {
|
|||
currency: string
|
||||
id: string
|
||||
last_four: string
|
||||
status: 'open' | 'closed'
|
||||
status: AccountStatus
|
||||
}
|
||||
|
||||
interface DepositoryAccount extends BaseAccount {
|
||||
|
@ -50,10 +52,10 @@ interface CreditAccount extends BaseAccount {
|
|||
export type Account = DepositoryAccount | CreditAccount
|
||||
|
||||
export type AccountWithBalances = Account & {
|
||||
balances: AccountBalance
|
||||
balance: AccountBalance
|
||||
}
|
||||
|
||||
export type GetAccountsResponse = Account[]
|
||||
export type GetAccountsResponse = AccountWithBalances[]
|
||||
export type GetAccountResponse = Account
|
||||
export type DeleteAccountResponse = void
|
||||
|
||||
|
|
13
libs/teller-api/src/types/enrollment.ts
Normal file
13
libs/teller-api/src/types/enrollment.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
export type Enrollment = {
|
||||
accessToken: string
|
||||
user: {
|
||||
id: string
|
||||
}
|
||||
enrollment: {
|
||||
id: string
|
||||
institution: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
signatures?: string[]
|
||||
}
|
|
@ -3,6 +3,7 @@ export * from './account-balance'
|
|||
export * from './account-details'
|
||||
export * from './authentication'
|
||||
export * from './error'
|
||||
export * from './enrollment'
|
||||
export * from './identity'
|
||||
export * from './institutions'
|
||||
export * from './transactions'
|
||||
|
|
|
@ -9,6 +9,4 @@ export type Institution = {
|
|||
|
||||
type Capability = 'detail' | 'balance' | 'transaction' | 'identity'
|
||||
|
||||
export type GetInstitutionsResponse = {
|
||||
institutions: Institution[]
|
||||
}
|
||||
export type GetInstitutionsResponse = Institution[]
|
||||
|
|
|
@ -147,12 +147,14 @@
|
|||
"react-popper": "^2.3.0",
|
||||
"react-ranger": "^2.1.0",
|
||||
"react-responsive": "^9.0.0-beta.10",
|
||||
"react-script-hook": "^1.7.2",
|
||||
"regenerator-runtime": "0.13.7",
|
||||
"sanitize-html": "^2.8.1",
|
||||
"smooth-scroll-into-view-if-needed": "^1.1.33",
|
||||
"stripe": "^10.17.0",
|
||||
"superjson": "^1.11.0",
|
||||
"tailwindcss": "3.2.4",
|
||||
"teller-connect-react": "^0.1.0",
|
||||
"tslib": "^2.3.0",
|
||||
"uuid": "^9.0.0",
|
||||
"winston": "^3.8.2",
|
||||
|
@ -163,6 +165,7 @@
|
|||
"@babel/core": "7.17.5",
|
||||
"@babel/preset-react": "^7.14.5",
|
||||
"@babel/preset-typescript": "7.16.7",
|
||||
"@faker-js/faker": "^8.3.1",
|
||||
"@fast-csv/parse": "^4.3.6",
|
||||
"@next/bundle-analyzer": "^13.1.1",
|
||||
"@nrwl/cli": "15.5.2",
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "Provider" ADD VALUE 'TELLER';
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `teller_account_id` on the `account_connection` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "account_connection" DROP COLUMN "teller_account_id",
|
||||
ADD COLUMN "teller_enrollment_id" TEXT;
|
|
@ -0,0 +1,223 @@
|
|||
-- AlterTable
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE
|
||||
"transaction" RENAME COLUMN "category" TO "category_old";
|
||||
|
||||
ALTER TABLE
|
||||
"transaction" RENAME COLUMN "category_user" TO "category_user_old";
|
||||
|
||||
DROP VIEW IF EXISTS transactions_enriched;
|
||||
|
||||
ALTER TABLE
|
||||
"transaction"
|
||||
ADD
|
||||
COLUMN "category_user" TEXT;
|
||||
|
||||
ALTER TABLE
|
||||
"transaction"
|
||||
ADD
|
||||
COLUMN "category" TEXT NOT NULL GENERATED ALWAYS AS(
|
||||
COALESCE(
|
||||
category_user,
|
||||
CASE
|
||||
WHEN (
|
||||
(
|
||||
plaid_personal_finance_category ->> 'primary' :: text
|
||||
) = 'INCOME' :: text
|
||||
) THEN 'Income' :: text
|
||||
WHEN (
|
||||
(
|
||||
plaid_personal_finance_category ->> 'detailed' :: text
|
||||
) = ANY (
|
||||
ARRAY ['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text]
|
||||
)
|
||||
) THEN 'Housing Payments' :: text
|
||||
WHEN (
|
||||
(
|
||||
plaid_personal_finance_category ->> 'detailed' :: text
|
||||
) = 'LOAN_PAYMENTS_CAR_PAYMENT' :: text
|
||||
) THEN 'Vehicle Payments' :: text
|
||||
WHEN (
|
||||
(
|
||||
plaid_personal_finance_category ->> 'primary' :: text
|
||||
) = 'LOAN_PAYMENTS' :: text
|
||||
) THEN 'Other Payments' :: text
|
||||
WHEN (
|
||||
(
|
||||
plaid_personal_finance_category ->> 'primary' :: text
|
||||
) = 'HOME_IMPROVEMENT' :: text
|
||||
) THEN 'Home Improvement' :: text
|
||||
WHEN (
|
||||
(
|
||||
plaid_personal_finance_category ->> 'primary' :: text
|
||||
) = 'GENERAL_MERCHANDISE' :: text
|
||||
) THEN 'Shopping' :: text
|
||||
WHEN (
|
||||
(
|
||||
(
|
||||
plaid_personal_finance_category ->> 'primary' :: text
|
||||
) = 'RENT_AND_UTILITIES' :: text
|
||||
)
|
||||
AND (
|
||||
(
|
||||
plaid_personal_finance_category ->> 'detailed' :: text
|
||||
) <> 'RENT_AND_UTILITIES_RENT' :: text
|
||||
)
|
||||
) THEN 'Utilities' :: text
|
||||
WHEN (
|
||||
(
|
||||
plaid_personal_finance_category ->> 'primary' :: text
|
||||
) = 'FOOD_AND_DRINK' :: text
|
||||
) THEN 'Food and Drink' :: text
|
||||
WHEN (
|
||||
(
|
||||
plaid_personal_finance_category ->> 'primary' :: text
|
||||
) = 'TRANSPORTATION' :: text
|
||||
) THEN 'Transportation' :: text
|
||||
WHEN (
|
||||
(
|
||||
plaid_personal_finance_category ->> 'primary' :: text
|
||||
) = 'TRAVEL' :: text
|
||||
) THEN 'Travel' :: text
|
||||
WHEN (
|
||||
(
|
||||
(
|
||||
plaid_personal_finance_category ->> 'primary' :: text
|
||||
) = ANY (ARRAY ['PERSONAL_CARE'::text, 'MEDICAL'::text])
|
||||
)
|
||||
AND (
|
||||
(
|
||||
plaid_personal_finance_category ->> 'detailed' :: text
|
||||
) <> 'MEDICAL_VETERINARY_SERVICES' :: text
|
||||
)
|
||||
) THEN 'Health' :: text
|
||||
WHEN (
|
||||
(finicity_categorization ->> 'category' :: text) = ANY (ARRAY ['Income'::text, 'Paycheck'::text])
|
||||
) THEN 'Income' :: text
|
||||
WHEN (
|
||||
(finicity_categorization ->> 'category' :: text) = 'Mortgage & Rent' :: text
|
||||
) THEN 'Housing Payments' :: text
|
||||
WHEN (
|
||||
(finicity_categorization ->> 'category' :: text) = ANY (
|
||||
ARRAY ['Furnishings'::text, 'Home Services'::text, 'Home Improvement'::text, 'Lawn and Garden'::text]
|
||||
)
|
||||
) THEN 'Home Improvement' :: text
|
||||
WHEN (
|
||||
(finicity_categorization ->> 'category' :: text) = ANY (
|
||||
ARRAY ['Streaming Services'::text, 'Home Phone'::text, 'Television'::text, 'Bills & Utilities'::text, 'Utilities'::text, 'Internet / Broadband Charges'::text, 'Mobile Phone'::text]
|
||||
)
|
||||
) THEN 'Utilities' :: text
|
||||
WHEN (
|
||||
(finicity_categorization ->> 'category' :: text) = ANY (
|
||||
ARRAY ['Fast Food'::text, 'Food & Dining'::text, 'Restaurants'::text, 'Coffee Shops'::text, 'Alcohol & Bars'::text, 'Groceries'::text]
|
||||
)
|
||||
) THEN 'Food and Drink' :: text
|
||||
WHEN (
|
||||
(finicity_categorization ->> 'category' :: text) = ANY (
|
||||
ARRAY ['Auto & Transport'::text, 'Gas & Fuel'::text, 'Auto Insurance'::text]
|
||||
)
|
||||
) THEN 'Transportation' :: text
|
||||
WHEN (
|
||||
(finicity_categorization ->> 'category' :: text) = ANY (
|
||||
ARRAY ['Hotel'::text, 'Travel'::text, 'Rental Car & Taxi'::text]
|
||||
)
|
||||
) THEN 'Travel' :: text
|
||||
WHEN (
|
||||
(finicity_categorization ->> 'category' :: text) = ANY (
|
||||
ARRAY ['Health Insurance'::text, 'Doctor'::text, 'Pharmacy'::text, 'Eyecare'::text, 'Health & Fitness'::text, 'Personal Care'::text]
|
||||
)
|
||||
) THEN 'Health' :: text
|
||||
WHEN (teller_category = 'income' :: text) THEN 'Income' :: text
|
||||
WHEN (teller_category = 'home' :: text) THEN 'Home Improvement' :: text
|
||||
WHEN (
|
||||
teller_category = ANY (ARRAY ['phone'::text, 'utilities'::text])
|
||||
) THEN 'Utilities' :: text
|
||||
WHEN (
|
||||
teller_category = ANY (
|
||||
ARRAY ['dining'::text, 'bar'::text, 'groceries'::text]
|
||||
)
|
||||
) THEN 'Food and Drink' :: text
|
||||
WHEN (
|
||||
teller_category = ANY (
|
||||
ARRAY ['clothing'::text, 'entertainment'::text, 'shopping'::text, 'electronics'::text, 'software'::text, 'sport'::text]
|
||||
)
|
||||
) THEN 'Shopping' :: text
|
||||
WHEN (
|
||||
teller_category = ANY (ARRAY ['transportation'::text, 'fuel'::text])
|
||||
) THEN 'Transportation' :: text
|
||||
WHEN (
|
||||
teller_category = ANY (ARRAY ['accommodation'::text, 'transport'::text])
|
||||
) THEN 'Travel' :: text
|
||||
WHEN (teller_category = 'health' :: text) THEN 'Health' :: text
|
||||
WHEN (
|
||||
teller_category = ANY (
|
||||
ARRAY ['loan'::text, 'tax'::text, 'insurance'::text, 'office'::text]
|
||||
)
|
||||
) THEN 'Other Payments' :: text
|
||||
ELSE 'Other' :: text
|
||||
END
|
||||
)
|
||||
) STORED;
|
||||
|
||||
CREATE
|
||||
OR REPLACE VIEW transactions_enriched AS (
|
||||
SELECT
|
||||
t.id,
|
||||
t.created_at as "createdAt",
|
||||
t.updated_at as "updatedAt",
|
||||
t.name,
|
||||
t.account_id as "accountId",
|
||||
t.date,
|
||||
t.flow,
|
||||
COALESCE(
|
||||
t.type_user,
|
||||
CASE
|
||||
-- no matching transaction
|
||||
WHEN t.match_id IS NULL THEN (
|
||||
CASE
|
||||
t.flow
|
||||
WHEN 'INFLOW' THEN (
|
||||
CASE
|
||||
a.classification
|
||||
WHEN 'asset' THEN 'INCOME' :: "TransactionType"
|
||||
WHEN 'liability' THEN 'PAYMENT' :: "TransactionType"
|
||||
END
|
||||
)
|
||||
WHEN 'OUTFLOW' THEN 'EXPENSE' :: "TransactionType"
|
||||
END
|
||||
) -- has matching transaction
|
||||
ELSE (
|
||||
CASE
|
||||
a.classification
|
||||
WHEN 'asset' THEN 'TRANSFER' :: "TransactionType"
|
||||
WHEN 'liability' THEN 'PAYMENT' :: "TransactionType"
|
||||
END
|
||||
)
|
||||
END
|
||||
) AS "type",
|
||||
t.type_user as "typeUser",
|
||||
t.amount,
|
||||
t.currency_code as "currencyCode",
|
||||
t.pending,
|
||||
t.merchant_name as "merchantName",
|
||||
t.category,
|
||||
t.category_user as "categoryUser",
|
||||
t.excluded,
|
||||
t.match_id as "matchId",
|
||||
COALESCE(ac.user_id, a.user_id) as "userId",
|
||||
a.classification as "accountClassification",
|
||||
a.type as "accountType"
|
||||
FROM
|
||||
transaction t
|
||||
inner join account a on a.id = t.account_id
|
||||
left join account_connection ac on a.account_connection_id = ac.id
|
||||
);
|
||||
|
||||
ALTER TABLE
|
||||
"transaction" DROP COLUMN "category_old";
|
||||
|
||||
ALTER TABLE
|
||||
"transaction" DROP COLUMN "category_user_old";
|
||||
|
||||
COMMIT;
|
|
@ -71,8 +71,8 @@ model AccountConnection {
|
|||
finicityError Json? @map("finicity_error")
|
||||
|
||||
// teller data
|
||||
tellerAccountId String? @map("teller_account_id")
|
||||
tellerAccessToken String? @map("teller_access_token")
|
||||
tellerEnrollmentId String? @map("teller_enrollment_id")
|
||||
tellerInstitutionId String? @map("teller_institution_id")
|
||||
tellerError Json? @map("teller_error")
|
||||
|
||||
|
@ -340,7 +340,7 @@ model Transaction {
|
|||
currencyCode String @default("USD") @map("currency_code")
|
||||
pending Boolean @default(false)
|
||||
merchantName String? @map("merchant_name")
|
||||
category String @default(dbgenerated("COALESCE(category_user,\nCASE\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'INCOME'::text) THEN 'Income'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = ANY (ARRAY['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text])) THEN 'Housing Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = 'LOAN_PAYMENTS_CAR_PAYMENT'::text) THEN 'Vehicle Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'LOAN_PAYMENTS'::text) THEN 'Other Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'HOME_IMPROVEMENT'::text) THEN 'Home Improvement'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'GENERAL_MERCHANDISE'::text) THEN 'Shopping'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = 'RENT_AND_UTILITIES'::text) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'RENT_AND_UTILITIES_RENT'::text)) THEN 'Utilities'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'FOOD_AND_DRINK'::text) THEN 'Food and Drink'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRANSPORTATION'::text) THEN 'Transportation'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRAVEL'::text) THEN 'Travel'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = ANY (ARRAY['PERSONAL_CARE'::text, 'MEDICAL'::text])) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'MEDICAL_VETERINARY_SERVICES'::text)) THEN 'Health'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Income'::text, 'Paycheck'::text])) THEN 'Income'::text\n WHEN ((finicity_categorization ->> 'category'::text) = 'Mortgage & Rent'::text) THEN 'Housing Payments'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Furnishings'::text, 'Home Services'::text, 'Home Improvement'::text, 'Lawn and Garden'::text])) THEN 'Home Improvement'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Streaming Services'::text, 'Home Phone'::text, 'Television'::text, 'Bills & Utilities'::text, 'Utilities'::text, 'Internet / Broadband Charges'::text, 'Mobile Phone'::text])) THEN 'Utilities'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Fast Food'::text, 'Food & Dining'::text, 'Restaurants'::text, 'Coffee Shops'::text, 'Alcohol & Bars'::text, 'Groceries'::text])) THEN 'Food and Drink'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Auto & Transport'::text, 'Gas & Fuel'::text, 'Auto Insurance'::text])) THEN 'Transportation'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Hotel'::text, 'Travel'::text, 'Rental Car & Taxi'::text])) THEN 'Travel'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Health Insurance'::text, 'Doctor'::text, 'Pharmacy'::text, 'Eyecare'::text, 'Health & Fitness'::text, 'Personal Care'::text])) THEN 'Health'::text\n ELSE 'Other'::text\nEND)"))
|
||||
category String @default(dbgenerated("COALESCE(category_user,\nCASE\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'INCOME'::text) THEN 'Income'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = ANY (ARRAY['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text])) THEN 'Housing Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = 'LOAN_PAYMENTS_CAR_PAYMENT'::text) THEN 'Vehicle Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'LOAN_PAYMENTS'::text) THEN 'Other Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'HOME_IMPROVEMENT'::text) THEN 'Home Improvement'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'GENERAL_MERCHANDISE'::text) THEN 'Shopping'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = 'RENT_AND_UTILITIES'::text) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'RENT_AND_UTILITIES_RENT'::text)) THEN 'Utilities'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'FOOD_AND_DRINK'::text) THEN 'Food and Drink'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRANSPORTATION'::text) THEN 'Transportation'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRAVEL'::text) THEN 'Travel'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = ANY (ARRAY['PERSONAL_CARE'::text, 'MEDICAL'::text])) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'MEDICAL_VETERINARY_SERVICES'::text)) THEN 'Health'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Income'::text, 'Paycheck'::text])) THEN 'Income'::text\n WHEN ((finicity_categorization ->> 'category'::text) = 'Mortgage & Rent'::text) THEN 'Housing Payments'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Furnishings'::text, 'Home Services'::text, 'Home Improvement'::text, 'Lawn and Garden'::text])) THEN 'Home Improvement'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Streaming Services'::text, 'Home Phone'::text, 'Television'::text, 'Bills & Utilities'::text, 'Utilities'::text, 'Internet / Broadband Charges'::text, 'Mobile Phone'::text])) THEN 'Utilities'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Fast Food'::text, 'Food & Dining'::text, 'Restaurants'::text, 'Coffee Shops'::text, 'Alcohol & Bars'::text, 'Groceries'::text])) THEN 'Food and Drink'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Auto & Transport'::text, 'Gas & Fuel'::text, 'Auto Insurance'::text])) THEN 'Transportation'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Hotel'::text, 'Travel'::text, 'Rental Car & Taxi'::text])) THEN 'Travel'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Health Insurance'::text, 'Doctor'::text, 'Pharmacy'::text, 'Eyecare'::text, 'Health & Fitness'::text, 'Personal Care'::text])) THEN 'Health'::text\n WHEN (teller_category = 'income'::text) THEN 'Income'::text\n WHEN (teller_category = 'home'::text) THEN 'Home Improvement'::text\n WHEN (teller_category = ANY (ARRAY['phone'::text, 'utilities'::text])) THEN 'Utilities'::text\n WHEN (teller_category = ANY (ARRAY['dining'::text, 'bar'::text, 'groceries'::text])) THEN 'Food and Drink'::text\n WHEN (teller_category = ANY (ARRAY['clothing'::text, 'entertainment'::text, 'shopping'::text, 'electronics'::text, 'software'::text, 'sport'::text])) THEN 'Shopping'::text\n WHEN (teller_category = ANY (ARRAY['transportation'::text, 'fuel'::text])) THEN 'Transportation'::text\n WHEN (teller_category = ANY (ARRAY['accommodation'::text, 'transport'::text])) THEN 'Travel'::text\n WHEN (teller_category = 'health'::text) THEN 'Health'::text\n WHEN (teller_category = ANY (ARRAY['loan'::text, 'tax'::text, 'insurance'::text, 'office'::text])) THEN 'Other Payments'::text\n ELSE 'Other'::text\nEND)"))
|
||||
categoryUser String? @map("category_user")
|
||||
excluded Boolean @default(false)
|
||||
|
||||
|
@ -493,6 +493,7 @@ model Institution {
|
|||
enum Provider {
|
||||
PLAID
|
||||
FINICITY
|
||||
TELLER
|
||||
}
|
||||
|
||||
model ProviderInstitution {
|
||||
|
|
1
tools/generators/index.ts
Normal file
1
tools/generators/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * as TellerGenerator from './tellerGenerator'
|
248
tools/generators/tellerGenerator.ts
Normal file
248
tools/generators/tellerGenerator.ts
Normal file
|
@ -0,0 +1,248 @@
|
|||
import { faker } from '@faker-js/faker'
|
||||
import type { TellerTypes } from '../../libs/teller-api/src'
|
||||
|
||||
function generateSubType(
|
||||
type: TellerTypes.AccountTypes
|
||||
): TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype {
|
||||
if (type === 'depository') {
|
||||
return faker.helpers.arrayElement([
|
||||
'checking',
|
||||
'savings',
|
||||
'money_market',
|
||||
'certificate_of_deposit',
|
||||
'treasury',
|
||||
'sweep',
|
||||
]) as TellerTypes.DepositorySubtypes
|
||||
} else {
|
||||
return 'credit_card' as TellerTypes.CreditSubtype
|
||||
}
|
||||
}
|
||||
|
||||
type GenerateAccountsParams = {
|
||||
count: number
|
||||
enrollmentId: string
|
||||
institutionName: string
|
||||
institutionId: string
|
||||
}
|
||||
|
||||
export function generateAccounts({
|
||||
count,
|
||||
enrollmentId,
|
||||
institutionName,
|
||||
institutionId,
|
||||
}: GenerateAccountsParams) {
|
||||
const accounts: TellerTypes.Account[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const accountId = faker.string.uuid()
|
||||
const lastFour = faker.finance.creditCardNumber().slice(-4)
|
||||
const type: TellerTypes.AccountTypes = faker.helpers.arrayElement(['depository', 'credit'])
|
||||
let subType: TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype
|
||||
subType = generateSubType(type)
|
||||
|
||||
const accountStub = {
|
||||
enrollment_id: enrollmentId,
|
||||
links: {
|
||||
balances: `https://api.teller.io/accounts/${accountId}/balances`,
|
||||
self: `https://api.teller.io/accounts/${accountId}`,
|
||||
transactions: `https://api.teller.io/accounts/${accountId}/transactions`,
|
||||
},
|
||||
institution: {
|
||||
name: institutionName,
|
||||
id: institutionId,
|
||||
},
|
||||
name: faker.finance.accountName(),
|
||||
currency: 'USD',
|
||||
id: accountId,
|
||||
last_four: lastFour,
|
||||
status: faker.helpers.arrayElement(['open', 'closed']) as TellerTypes.AccountStatus,
|
||||
}
|
||||
|
||||
if (faker.datatype.boolean()) {
|
||||
accounts.push({
|
||||
...accountStub,
|
||||
type: 'depository',
|
||||
subtype: faker.helpers.arrayElement([
|
||||
'checking',
|
||||
'savings',
|
||||
'money_market',
|
||||
'certificate_of_deposit',
|
||||
'treasury',
|
||||
'sweep',
|
||||
]),
|
||||
})
|
||||
} else {
|
||||
accounts.push({
|
||||
...accountStub,
|
||||
type: 'credit',
|
||||
subtype: 'credit_card',
|
||||
})
|
||||
}
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
export function generateBalance(account_id: string): TellerTypes.AccountBalance {
|
||||
const amount = faker.finance.amount()
|
||||
return {
|
||||
available: amount,
|
||||
ledger: amount,
|
||||
links: {
|
||||
account: `https://api.teller.io/accounts/${account_id}`,
|
||||
self: `https://api.teller.io/accounts/${account_id}/balances`,
|
||||
},
|
||||
account_id,
|
||||
}
|
||||
}
|
||||
|
||||
type GenerateAccountsWithBalancesParams = {
|
||||
count: number
|
||||
enrollmentId: string
|
||||
institutionName: string
|
||||
institutionId: string
|
||||
}
|
||||
|
||||
export function generateAccountsWithBalances({
|
||||
count,
|
||||
enrollmentId,
|
||||
institutionName,
|
||||
institutionId,
|
||||
}: GenerateAccountsWithBalancesParams): TellerTypes.AccountWithBalances[] {
|
||||
const accountsWithBalances: TellerTypes.AccountWithBalances[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const account = generateAccounts({
|
||||
count,
|
||||
enrollmentId,
|
||||
institutionName,
|
||||
institutionId,
|
||||
})[0]
|
||||
const balance = generateBalance(account.id)
|
||||
accountsWithBalances.push({
|
||||
...account,
|
||||
balance,
|
||||
})
|
||||
}
|
||||
return accountsWithBalances
|
||||
}
|
||||
|
||||
export function generateTransactions(count: number, accountId: string): TellerTypes.Transaction[] {
|
||||
const transactions: TellerTypes.Transaction[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const transactionId = `txn_${faker.string.uuid()}`
|
||||
const transaction = {
|
||||
details: {
|
||||
processing_status: faker.helpers.arrayElement(['complete', 'pending']),
|
||||
category: faker.helpers.arrayElement([
|
||||
'accommodation',
|
||||
'advertising',
|
||||
'bar',
|
||||
'charity',
|
||||
'clothing',
|
||||
'dining',
|
||||
'education',
|
||||
'electronics',
|
||||
'entertainment',
|
||||
'fuel',
|
||||
'general',
|
||||
'groceries',
|
||||
'health',
|
||||
'home',
|
||||
'income',
|
||||
'insurance',
|
||||
'investment',
|
||||
'loan',
|
||||
'office',
|
||||
'phone',
|
||||
'service',
|
||||
'shopping',
|
||||
'software',
|
||||
'sport',
|
||||
'tax',
|
||||
'transport',
|
||||
'transportation',
|
||||
'utilities',
|
||||
]),
|
||||
counterparty: {
|
||||
name: faker.company.name(),
|
||||
type: faker.helpers.arrayElement(['person', 'business']),
|
||||
},
|
||||
},
|
||||
running_balance: null,
|
||||
description: faker.word.words({ count: { min: 3, max: 10 } }),
|
||||
id: transactionId,
|
||||
date: faker.date.recent({ days: 30 }).toISOString().split('T')[0], // recent date in 'YYYY-MM-DD' format
|
||||
account_id: accountId,
|
||||
links: {
|
||||
account: `https://api.teller.io/accounts/${accountId}`,
|
||||
self: `https://api.teller.io/accounts/${accountId}/transactions/${transactionId}`,
|
||||
},
|
||||
amount: faker.finance.amount(),
|
||||
type: faker.helpers.arrayElement(['transfer', 'deposit', 'withdrawal']),
|
||||
status: faker.helpers.arrayElement(['pending', 'posted']),
|
||||
} as TellerTypes.Transaction
|
||||
transactions.push(transaction)
|
||||
}
|
||||
return transactions
|
||||
}
|
||||
|
||||
export function generateEnrollment(): TellerTypes.Enrollment & { institutionId: string } {
|
||||
const institutionName = faker.company.name()
|
||||
const institutionId = institutionName.toLowerCase().replace(/\s/g, '_')
|
||||
return {
|
||||
accessToken: `token_${faker.string.alphanumeric(15)}`,
|
||||
user: {
|
||||
id: `usr_${faker.string.alphanumeric(15)}`,
|
||||
},
|
||||
enrollment: {
|
||||
id: `enr_${faker.string.alphanumeric(15)}`,
|
||||
institution: {
|
||||
name: institutionName,
|
||||
},
|
||||
},
|
||||
signatures: [faker.string.alphanumeric(15)],
|
||||
institutionId,
|
||||
}
|
||||
}
|
||||
|
||||
type GenerateConnectionsResponse = {
|
||||
enrollment: TellerTypes.Enrollment & { institutionId: string }
|
||||
accounts: TellerTypes.Account[]
|
||||
accountsWithBalances: TellerTypes.AccountWithBalances[]
|
||||
transactions: TellerTypes.Transaction[]
|
||||
}
|
||||
|
||||
export function generateConnection(): GenerateConnectionsResponse {
|
||||
const accountsWithBalances: TellerTypes.AccountWithBalances[] = []
|
||||
const accounts: TellerTypes.Account[] = []
|
||||
const transactions: TellerTypes.Transaction[] = []
|
||||
|
||||
const enrollment = generateEnrollment()
|
||||
|
||||
const accountCount: number = faker.number.int({ min: 1, max: 3 })
|
||||
|
||||
const enrollmentId = enrollment.enrollment.id
|
||||
const institutionName = enrollment.enrollment.institution.name
|
||||
const institutionId = enrollment.institutionId
|
||||
accountsWithBalances.push(
|
||||
...generateAccountsWithBalances({
|
||||
count: accountCount,
|
||||
enrollmentId,
|
||||
institutionName,
|
||||
institutionId,
|
||||
})
|
||||
)
|
||||
for (const account of accountsWithBalances) {
|
||||
const { balance, ...accountWithoutBalance } = account
|
||||
accounts.push(accountWithoutBalance)
|
||||
const transactionsCount: number = faker.number.int({ min: 1, max: 5 })
|
||||
const generatedTransactions = generateTransactions(transactionsCount, account.id)
|
||||
transactions.push(...generatedTransactions)
|
||||
}
|
||||
|
||||
return {
|
||||
enrollment,
|
||||
accounts,
|
||||
accountsWithBalances,
|
||||
transactions,
|
||||
}
|
||||
}
|
|
@ -405,7 +405,7 @@
|
|||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"outputs": ["/coverage/libs/teller-api"],
|
||||
"options": {
|
||||
"jestConfig": "libs/teller-api/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
|
|
17
yarn.lock
17
yarn.lock
|
@ -1730,6 +1730,11 @@
|
|||
minimatch "^3.1.2"
|
||||
strip-json-comments "^3.1.1"
|
||||
|
||||
"@faker-js/faker@^8.3.1":
|
||||
version "8.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-8.3.1.tgz#7753df0cb88d7649becf984a96dd1bd0a26f43e3"
|
||||
integrity sha512-FdgpFxY6V6rLZE9mmIBb9hM0xpfvQOSNOLnzolzKwsE1DH+gC7lEKV1p1IbR0lAYyvYd5a4u3qWJzowUkw1bIw==
|
||||
|
||||
"@fast-csv/format@^4.3.5":
|
||||
version "4.3.5"
|
||||
resolved "https://registry.yarnpkg.com/@fast-csv/format/-/format-4.3.5.tgz#90d83d1b47b6aaf67be70d6118f84f3e12ee1ff3"
|
||||
|
@ -16600,6 +16605,11 @@ react-script-hook@^1.6.0:
|
|||
resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.6.0.tgz#6a44ff5e65113cb29252eadad1b8306f5fe0c626"
|
||||
integrity sha512-aJm72XGWV+wJTKiqHmAaTNC/JQZV/Drv6A1kd1VQlzhzAXLqtBRBeTt3iTESImGe5TaBDHUOUeaGNw4v+7bqDw==
|
||||
|
||||
react-script-hook@^1.7.2:
|
||||
version "1.7.2"
|
||||
resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.7.2.tgz#ec130d67f9a25fcde57fbfd1faa87e5b97521948"
|
||||
integrity sha512-fhyCEfXb94fag34UPRF0zry1XGwmVY+79iibWwTqAoOiCzYJQOYTiWJ7CnqglA9tMSV8g45cQpHCMcBwr7dwhA==
|
||||
|
||||
react-shallow-renderer@^16.15.0:
|
||||
version "16.15.0"
|
||||
resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz#48fb2cf9b23d23cde96708fe5273a7d3446f4457"
|
||||
|
@ -18532,6 +18542,13 @@ telejson@^6.0.8:
|
|||
lodash "^4.17.21"
|
||||
memoizerific "^1.11.3"
|
||||
|
||||
teller-connect-react@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/teller-connect-react/-/teller-connect-react-0.1.0.tgz#b3bae24f4410d622eb8c88c7668adb003eb7bfd7"
|
||||
integrity sha512-ZI+OULCsuo/v1qetpjepOgM7TyIzwnMVE/54IruOPguQtJ/Ui3C1ax3wUb65AKZDyVQ7ZyjA+8ypT/yMYD9bIQ==
|
||||
dependencies:
|
||||
react-script-hook "^1.7.2"
|
||||
|
||||
terminal-link@^2.0.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue