1
0
Fork 0
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:
Josh Pigford 2024-01-17 21:02:59 -06:00 committed by GitHub
commit 2eed43f6e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1133 additions and 106 deletions

View file

@ -35,7 +35,8 @@ NX_POLYGON_API_KEY=
# We use Teller.io for automated banking data. You can sign up for a free
# account and get a free API key at https://teller.io
NX_TELLER_SIGNING_SECRET=
NX_TELLER_APP_ID=
NEXT_PUBLIC_TELLER_APP_ID=
NEXT_PUBLIC_TELLER_ENV=sandbox
NX_TELLER_ENV=sandbox
########################################################################
@ -57,4 +58,4 @@ NX_POSTMARK_API_TOKEN=
########################################################################
NX_PLAID_SECRET=
NX_FINICITY_APP_KEY=
NX_FINICITY_PARTNER_SECRET=
NX_FINICITY_PARTNER_SECRET=

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,45 @@
import { Router } from 'express'
import { z } from 'zod'
import endpoint from '../lib/endpoint'
const router = Router()
router.post(
'/handle-enrollment',
endpoint.create({
input: z.object({
institution: z.object({
name: z.string(),
id: z.string(),
}),
enrollment: z.object({
accessToken: z.string(),
user: z.object({
id: z.string(),
}),
enrollment: z.object({
id: z.string(),
institution: z.object({
name: z.string(),
}),
}),
signatures: z.array(z.string()).optional(),
}),
}),
resolve: ({ input: { institution, enrollment }, ctx }) => {
return ctx.tellerService.handleEnrollment(ctx.user!.id, institution, enrollment)
},
})
)
router.post(
'/institutions/sync',
endpoint.create({
resolve: async ({ ctx }) => {
ctx.ability.throwUnlessCan('manage', 'Institution')
await ctx.queueService.getQueue('sync-institution').add('sync-teller-institutions', {})
},
})
)
export default router

View file

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

View file

@ -0,0 +1,118 @@
import type { User } from '@prisma/client'
import { TellerGenerator } from '../../../../../tools/generators'
import { TellerApi } from '@maybe-finance/teller-api'
jest.mock('@maybe-finance/teller-api')
import {
TellerETL,
TellerService,
type IAccountConnectionProvider,
} from '@maybe-finance/server/features'
import { createLogger } from '@maybe-finance/server/shared'
import prisma from '../lib/prisma'
import { resetUser } from './helpers/user.test-helper'
import { transports } from 'winston'
import { cryptoService } from '../lib/di'
const logger = createLogger({ level: 'debug', transports: [new transports.Console()] })
const teller = jest.mocked(new TellerApi())
const tellerETL = new TellerETL(logger, prisma, teller, cryptoService)
const service: IAccountConnectionProvider = new TellerService(
logger,
prisma,
teller,
tellerETL,
cryptoService,
'TELLER_WEBHOOK_URL',
true
)
afterAll(async () => {
await prisma.$disconnect()
})
describe('Teller', () => {
let user: User
beforeEach(async () => {
jest.clearAllMocks()
user = await resetUser(prisma)
})
it('syncs connection', async () => {
const tellerConnection = TellerGenerator.generateConnection()
const tellerAccounts = tellerConnection.accountsWithBalances
const tellerTransactions = tellerConnection.transactions
teller.getAccounts.mockResolvedValue(tellerAccounts)
teller.getTransactions.mockImplementation(async ({ accountId }) => {
return Promise.resolve(tellerTransactions.filter((t) => t.account_id === accountId))
})
const connection = await prisma.accountConnection.create({
data: {
userId: user.id,
name: 'TEST_TELLER',
type: 'teller',
tellerEnrollmentId: tellerConnection.enrollment.enrollment.id,
tellerInstitutionId: tellerConnection.enrollment.institutionId,
tellerAccessToken: cryptoService.encrypt(tellerConnection.enrollment.accessToken),
},
})
await service.sync(connection)
const { accounts } = await prisma.accountConnection.findUniqueOrThrow({
where: {
id: connection.id,
},
include: {
accounts: {
include: {
transactions: true,
investmentTransactions: true,
holdings: true,
valuations: true,
},
},
},
})
// all accounts
expect(accounts).toHaveLength(tellerConnection.accounts.length)
for (const account of accounts) {
expect(account.transactions).toHaveLength(
tellerTransactions.filter((t) => t.account_id === account.tellerAccountId).length
)
}
// credit accounts
const creditAccounts = tellerAccounts.filter((a) => a.type === 'credit')
expect(accounts.filter((a) => a.type === 'CREDIT')).toHaveLength(creditAccounts.length)
for (const creditAccount of creditAccounts) {
const account = accounts.find((a) => a.tellerAccountId === creditAccount.id)!
expect(account.transactions).toHaveLength(
tellerTransactions.filter((t) => t.account_id === account.tellerAccountId).length
)
expect(account.holdings).toHaveLength(0)
expect(account.valuations).toHaveLength(0)
expect(account.investmentTransactions).toHaveLength(0)
}
// depository accounts
const depositoryAccounts = tellerAccounts.filter((a) => a.type === 'depository')
expect(accounts.filter((a) => a.type === 'DEPOSITORY')).toHaveLength(
depositoryAccounts.length
)
for (const depositoryAccount of depositoryAccounts) {
const account = accounts.find((a) => a.tellerAccountId === depositoryAccount.id)!
expect(account.transactions).toHaveLength(
tellerTransactions.filter((t) => t.account_id === account.tellerAccountId).length
)
expect(account.holdings).toHaveLength(0)
expect(account.valuations).toHaveLength(0)
expect(account.investmentTransactions).toHaveLength(0)
}
})
})

View file

@ -259,6 +259,7 @@ export const securityPricingProcessor: ISecurityPricingProcessor = new SecurityP
const institutionProviderFactory = new InstitutionProviderFactory({
PLAID: plaidService,
FINICITY: finicityService,
TELLER: tellerService,
})
export const institutionService: IInstitutionService = new InstitutionService(

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,64 @@
import { useMemo } from 'react'
import toast from 'react-hot-toast'
import { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { SharedType } from '@maybe-finance/shared'
import type { AxiosInstance } from 'axios'
import type { TellerTypes } from '@maybe-finance/teller-api'
import { useAccountConnectionApi } from './useAccountConnectionApi'
type TellerInstitution = {
name: string
id: string
}
const TellerApi = (axios: AxiosInstance) => ({
async handleEnrollment(input: {
institution: TellerInstitution
enrollment: TellerTypes.Enrollment
}) {
const { data } = await axios.post<SharedType.AccountConnection>(
'/teller/handle-enrollment',
input
)
return data
},
})
export function useTellerApi() {
const queryClient = useQueryClient()
const { axios } = useAxiosWithAuth()
const api = useMemo(() => TellerApi(axios), [axios])
const { useSyncConnection } = useAccountConnectionApi()
const syncConnection = useSyncConnection()
const addConnectionToState = (connection: SharedType.AccountConnection) => {
const accountsData = queryClient.getQueryData<SharedType.AccountsResponse>(['accounts'])
if (!accountsData)
queryClient.setQueryData<SharedType.AccountsResponse>(['accounts'], {
connections: [{ ...connection, accounts: [] }],
accounts: [],
})
else {
const { connections, ...rest } = accountsData
queryClient.setQueryData<SharedType.AccountsResponse>(['accounts'], {
connections: [...connections, { ...connection, accounts: [] }],
...rest,
})
}
}
const useHandleEnrollment = () =>
useMutation(api.handleEnrollment, {
onSuccess: (_connection) => {
addConnectionToState(_connection)
syncConnection.mutate(_connection.id)
toast.success(`Account connection added!`)
},
})
return {
useHandleEnrollment,
}
}

View file

@ -9,5 +9,6 @@ export * from './useQueryParam'
export * from './useScreenSize'
export * from './useAccountNotifications'
export * from './usePlaid'
export * from './useTeller'
export * from './useProviderStatus'
export * from './useModalManager'

View file

@ -0,0 +1,182 @@
import { useEffect, useState } from 'react'
import * as Sentry from '@sentry/react'
import type { Logger } from '../providers/LogProvider'
import toast from 'react-hot-toast'
import { useAccountContext } from '../providers'
import { useTellerApi } from '../api'
import type {
TellerConnectEnrollment,
TellerConnectFailure,
TellerConnectOptions,
TellerConnectInstance,
} from 'teller-connect-react'
import useScript from 'react-script-hook'
type TellerEnvironment = 'sandbox' | 'development' | 'production' | undefined
type TellerAccountSelection = 'disabled' | 'single' | 'multiple' | undefined
const TC_JS = 'https://cdn.teller.io/connect/connect.js'
// Create the base configuration for Teller Connect
export const useTellerConfig = (logger: Logger) => {
return {
applicationId: process.env.NEXT_PUBLIC_TELLER_APP_ID ?? 'ADD_TELLER_APP_ID',
environment: (process.env.NEXT_PUBLIC_TELLER_ENV as TellerEnvironment) ?? 'sandbox',
selectAccount: 'disabled' as TellerAccountSelection,
onInit: () => {
logger.debug(`Teller Connect has initialized`)
},
onSuccess: {},
onExit: () => {
logger.debug(`Teller Connect exited`)
},
onFailure: (failure: TellerConnectFailure) => {
logger.error(`Teller Connect exited with error`, failure)
Sentry.captureEvent({
level: 'error',
message: 'TELLER_CONNECT_ERROR',
tags: {
'teller.error.code': failure.code,
'teller.error.message': failure.message,
},
})
},
} as TellerConnectOptions
}
// Custom implementation of useTellerHook to handle institution id being passed in
export const useTellerConnect = (options: TellerConnectOptions, logger: Logger) => {
const { useHandleEnrollment } = useTellerApi()
const handleEnrollment = useHandleEnrollment()
const { setAccountManager } = useAccountContext()
const [loading, error] = useScript({
src: TC_JS,
checkForExisting: true,
})
const [teller, setTeller] = useState<TellerConnectInstance | null>(null)
const [iframeLoaded, setIframeLoaded] = useState(false)
const createTellerInstance = (institutionId: string) => {
return createTeller(
{
...options,
onSuccess: async (enrollment: TellerConnectEnrollment) => {
logger.debug('User enrolled successfully')
try {
await handleEnrollment.mutateAsync({
institution: {
id: institutionId!,
name: enrollment.enrollment.institution.name,
},
enrollment,
})
} catch (error) {
toast.error(`Failed to add account`)
}
},
institution: institutionId,
onInit: () => {
setIframeLoaded(true)
options.onInit && options.onInit()
},
},
window.TellerConnect.setup
)
}
useEffect(() => {
if (loading) {
return
}
if (!options.applicationId) {
return
}
if (error || !window.TellerConnect) {
console.error('Error loading TellerConnect:', error)
return
}
if (teller != null) {
teller.destroy()
}
return () => teller?.destroy()
}, [
loading,
error,
options.applicationId,
options.enrollmentId,
options.connectToken,
options.products,
])
const ready = teller != null && (!loading || iframeLoaded)
const logIt = () => {
if (!options.applicationId) {
console.error('teller-connect-react: open() called without a valid applicationId.')
}
}
return {
error,
ready,
open: (institutionId: string) => {
logIt()
const tellerInstance = createTellerInstance(institutionId)
tellerInstance.open()
setAccountManager({ view: 'idle' })
},
}
}
interface ManagerState {
teller: TellerConnectInstance | null
open: boolean
}
export const createTeller = (
config: TellerConnectOptions,
creator: (config: TellerConnectOptions) => TellerConnectInstance
) => {
const state: ManagerState = {
teller: null,
open: false,
}
if (typeof window === 'undefined' || !window.TellerConnect) {
throw new Error('TellerConnect is not loaded')
}
state.teller = creator({
...config,
onExit: () => {
state.open = false
config.onExit && config.onExit()
},
})
const open = () => {
if (!state.teller) {
return
}
state.open = true
state.teller.open()
}
const destroy = () => {
if (!state.teller) {
return
}
state.teller.destroy()
state.teller = null
}
return {
open,
destroy,
}
}

View file

@ -49,6 +49,7 @@ type AccountManager =
| { view: 'idle' }
| { view: 'add-plaid'; linkToken: string }
| { view: 'add-finicity' }
| { view: 'add-teller' }
| { view: 'add-account' }
| { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> }
| { view: 'add-vehicle'; defaultValues: Partial<CreateVehicleFields> }

View file

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

View file

@ -1,6 +1,6 @@
import type { AccountConnection, PrismaClient } from '@prisma/client'
import type { Logger } from 'winston'
import { SharedUtil, AccountUtil, type SharedType } from '@maybe-finance/shared'
import { AccountUtil, SharedUtil, type SharedType } from '@maybe-finance/shared'
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
import { DbUtil, TellerUtil, type IETL, type ICryptoService } from '@maybe-finance/server/shared'
import { Prisma } from '@prisma/client'
@ -101,16 +101,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
private async _extractAccounts(accessToken: string) {
const accounts = await this.teller.getAccounts({ accessToken })
const accountsWithBalances = await Promise.all(
accounts.map(async (a) => {
const balance = await this.teller.getAccountBalances({
accountId: a.id,
accessToken,
})
return { ...a, balance }
})
)
return accountsWithBalances
return accounts
}
private _loadAccounts(connection: Connection, { accounts }: Pick<TellerData, 'accounts'>) {
@ -119,6 +110,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
...accounts.map((tellerAccount) => {
const type = TellerUtil.getType(tellerAccount.type)
const classification = AccountUtil.getClassification(type)
return this.prisma.account.upsert({
where: {
accountConnectionId_tellerAccountId: {
@ -132,6 +124,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type),
subcategoryProvider: tellerAccount.subtype ?? 'other',
accountConnectionId: connection.id,
userId: connection.userId,
tellerAccountId: tellerAccount.id,
name: tellerAccount.name,
tellerType: tellerAccount.type,
@ -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 ?? ''}
)`
})
)}

View file

@ -6,19 +6,11 @@ import type {
IAccountConnectionProvider,
} from '../../account-connection'
import { SharedUtil } from '@maybe-finance/shared'
import type { SharedType } from '@maybe-finance/shared'
import type { SyncConnectionOptions, CryptoService, IETL } from '@maybe-finance/server/shared'
import _ from 'lodash'
import { ErrorUtil, etl } from '@maybe-finance/server/shared'
import type { TellerApi } from '@maybe-finance/teller-api'
export interface ITellerConnect {
generateConnectUrl(userId: User['id'], institutionId: string): Promise<{ link: string }>
generateFixConnectUrl(
userId: User['id'],
accountConnectionId: AccountConnection['id']
): Promise<{ link: string }>
}
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
export class TellerService implements IAccountConnectionProvider, IInstitutionProvider {
constructor(
@ -44,6 +36,7 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
where: { id: connection.id },
data: {
status: 'OK',
syncStatus: 'IDLE',
},
})
break
@ -67,19 +60,28 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
async delete(connection: AccountConnection) {
// purge teller data
if (connection.tellerAccessToken && connection.tellerAccountId) {
await this.teller.deleteAccount({
accessToken: this.crypto.decrypt(connection.tellerAccessToken),
accountId: connection.tellerAccountId,
if (connection.tellerAccessToken && connection.tellerEnrollmentId) {
const accounts = await this.prisma.account.findMany({
where: { accountConnectionId: connection.id },
})
this.logger.info(`Item ${connection.tellerAccountId} removed`)
for (const account of accounts) {
if (!account.tellerAccountId) continue
await this.teller.deleteAccount({
accessToken: this.crypto.decrypt(connection.tellerAccessToken),
accountId: account.tellerAccountId,
})
this.logger.info(`Teller account ${account.id} removed`)
}
this.logger.info(`Teller enrollment ${connection.tellerEnrollmentId} removed`)
}
}
async getInstitutions() {
const tellerInstitutions = await SharedUtil.paginate({
pageSize: 500,
pageSize: 10000,
delay:
process.env.NODE_ENV !== 'production'
? {
@ -87,20 +89,20 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
milliseconds: 7_000, // Sandbox rate limited at 10 calls / minute
}
: undefined,
fetchData: (offset, count) =>
fetchData: () =>
SharedUtil.withRetry(
() =>
this.teller.getInstitutions().then((data) => {
this.logger.debug(
`paginated teller fetch inst=${data.institutions.length} (total=${data.institutions.length} offset=${offset} count=${count})`
`teller fetch inst=${data.length} (total=${data.length})`
)
return data.institutions
return data
}),
{
maxRetries: 3,
onError: (error, attempt) => {
this.logger.error(
`Teller fetch institutions request failed attempt=${attempt} offset=${offset} count=${count}`,
`Teller fetch institutions request failed attempt=${attempt}`,
{ error: ErrorUtil.parseError(error) }
)
@ -115,12 +117,57 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
return {
providerId: id,
name,
url: undefined,
logo: `https://teller.io/images/banks/${id}.jpg}`,
primaryColor: undefined,
oauth: undefined,
url: null,
logo: null,
logoUrl: `https://teller.io/images/banks/${id}.jpg`,
primaryColor: null,
oauth: false,
data: tellerInstitution,
}
})
}
async handleEnrollment(
userId: User['id'],
institution: Pick<TellerTypes.Institution, 'name' | 'id'>,
enrollment: TellerTypes.Enrollment
) {
const connections = await this.prisma.accountConnection.findMany({
where: { userId },
})
if (connections.length > 40) {
throw new Error('MAX_ACCOUNT_CONNECTIONS')
}
const accounts = await this.teller.getAccounts({ accessToken: enrollment.accessToken })
this.logger.info(`Teller accounts retrieved for enrollment ${enrollment.enrollment.id}`)
// If all the accounts are Non-USD, throw an error
if (accounts.every((a) => a.currency !== 'USD')) {
throw new Error('USD_ONLY')
}
await this.prisma.user.update({
where: { id: userId },
data: {
tellerUserId: enrollment.user.id,
},
})
const accountConnection = await this.prisma.accountConnection.create({
data: {
name: enrollment.enrollment.institution.name,
type: 'teller' as SharedType.AccountConnectionType,
tellerEnrollmentId: enrollment.enrollment.id,
tellerInstitutionId: institution.id,
tellerAccessToken: this.crypto.encrypt(enrollment.accessToken),
userId,
syncStatus: 'PENDING',
},
})
return accountConnection
}
}

View file

@ -70,7 +70,7 @@ export type SyncSecurityQueue = IQueue<SyncSecurityQueueJobData, 'sync-all-secur
export type PurgeUserQueue = IQueue<{ userId: User['id'] }, 'purge-user'>
export type SyncInstitutionQueue = IQueue<
{},
'sync-finicity-institutions' | 'sync-plaid-institutions'
'sync-finicity-institutions' | 'sync-plaid-institutions' | 'sync-teller-institutions'
>
export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>

View file

@ -2,8 +2,8 @@ import {
Prisma,
AccountCategory,
AccountType,
type AccountClassification,
type Account,
type AccountClassification,
} from '@prisma/client'
import type { TellerTypes } from '@maybe-finance/teller-api'
import { Duration } from 'luxon'
@ -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,

View file

@ -34,7 +34,20 @@ export class TellerApi {
*/
async getAccounts({ accessToken }: AuthenticatedRequest): Promise<GetAccountsResponse> {
return this.get<GetAccountsResponse>(`/accounts`, accessToken)
const accounts = await this.get<GetAccountsResponse>(`/accounts`, accessToken)
const accountsWithBalances = await Promise.all(
accounts.map(async (account) => {
const balance = await this.getAccountBalances({
accountId: account.id,
accessToken,
})
return {
...account,
balance,
}
})
)
return accountsWithBalances
}
/**
@ -137,12 +150,12 @@ export class TellerApi {
}
private async getApi(accessToken: string): Promise<AxiosInstance> {
const cert = fs.readFileSync('../../../certs/teller-certificate.pem', 'utf8')
const key = fs.readFileSync('../../../certs/teller-private-key.pem', 'utf8')
const cert = fs.readFileSync('./certs/certificate.pem')
const key = fs.readFileSync('./certs/private_key.pem')
const agent = new https.Agent({
cert,
key,
cert: cert,
key: key,
})
if (!this.api) {
@ -153,16 +166,16 @@ export class TellerApi {
headers: {
Accept: 'application/json',
},
auth: {
username: accessToken,
password: '',
},
})
this.api.interceptors.request.use((config) => {
// Add the access_token to the auth object
config.auth = {
username: 'ACCESS_TOKEN',
password: accessToken,
}
return config
})
} else if (this.api.defaults.auth?.username !== accessToken) {
this.api.defaults.auth = {
username: accessToken,
password: '',
}
}
return this.api

View file

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

View file

@ -0,0 +1,13 @@
export type Enrollment = {
accessToken: string
user: {
id: string
}
enrollment: {
id: string
institution: {
name: string
}
}
signatures?: string[]
}

View file

@ -3,6 +3,7 @@ export * from './account-balance'
export * from './account-details'
export * from './authentication'
export * from './error'
export * from './enrollment'
export * from './identity'
export * from './institutions'
export * from './transactions'

View file

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

View file

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

View file

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Provider" ADD VALUE 'TELLER';

View file

@ -0,0 +1,9 @@
/*
Warnings:
- You are about to drop the column `teller_account_id` on the `account_connection` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "account_connection" DROP COLUMN "teller_account_id",
ADD COLUMN "teller_enrollment_id" TEXT;

View file

@ -0,0 +1,223 @@
-- AlterTable
BEGIN;
ALTER TABLE
"transaction" RENAME COLUMN "category" TO "category_old";
ALTER TABLE
"transaction" RENAME COLUMN "category_user" TO "category_user_old";
DROP VIEW IF EXISTS transactions_enriched;
ALTER TABLE
"transaction"
ADD
COLUMN "category_user" TEXT;
ALTER TABLE
"transaction"
ADD
COLUMN "category" TEXT NOT NULL GENERATED ALWAYS AS(
COALESCE(
category_user,
CASE
WHEN (
(
plaid_personal_finance_category ->> 'primary' :: text
) = 'INCOME' :: text
) THEN 'Income' :: text
WHEN (
(
plaid_personal_finance_category ->> 'detailed' :: text
) = ANY (
ARRAY ['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text]
)
) THEN 'Housing Payments' :: text
WHEN (
(
plaid_personal_finance_category ->> 'detailed' :: text
) = 'LOAN_PAYMENTS_CAR_PAYMENT' :: text
) THEN 'Vehicle Payments' :: text
WHEN (
(
plaid_personal_finance_category ->> 'primary' :: text
) = 'LOAN_PAYMENTS' :: text
) THEN 'Other Payments' :: text
WHEN (
(
plaid_personal_finance_category ->> 'primary' :: text
) = 'HOME_IMPROVEMENT' :: text
) THEN 'Home Improvement' :: text
WHEN (
(
plaid_personal_finance_category ->> 'primary' :: text
) = 'GENERAL_MERCHANDISE' :: text
) THEN 'Shopping' :: text
WHEN (
(
(
plaid_personal_finance_category ->> 'primary' :: text
) = 'RENT_AND_UTILITIES' :: text
)
AND (
(
plaid_personal_finance_category ->> 'detailed' :: text
) <> 'RENT_AND_UTILITIES_RENT' :: text
)
) THEN 'Utilities' :: text
WHEN (
(
plaid_personal_finance_category ->> 'primary' :: text
) = 'FOOD_AND_DRINK' :: text
) THEN 'Food and Drink' :: text
WHEN (
(
plaid_personal_finance_category ->> 'primary' :: text
) = 'TRANSPORTATION' :: text
) THEN 'Transportation' :: text
WHEN (
(
plaid_personal_finance_category ->> 'primary' :: text
) = 'TRAVEL' :: text
) THEN 'Travel' :: text
WHEN (
(
(
plaid_personal_finance_category ->> 'primary' :: text
) = ANY (ARRAY ['PERSONAL_CARE'::text, 'MEDICAL'::text])
)
AND (
(
plaid_personal_finance_category ->> 'detailed' :: text
) <> 'MEDICAL_VETERINARY_SERVICES' :: text
)
) THEN 'Health' :: text
WHEN (
(finicity_categorization ->> 'category' :: text) = ANY (ARRAY ['Income'::text, 'Paycheck'::text])
) THEN 'Income' :: text
WHEN (
(finicity_categorization ->> 'category' :: text) = 'Mortgage & Rent' :: text
) THEN 'Housing Payments' :: text
WHEN (
(finicity_categorization ->> 'category' :: text) = ANY (
ARRAY ['Furnishings'::text, 'Home Services'::text, 'Home Improvement'::text, 'Lawn and Garden'::text]
)
) THEN 'Home Improvement' :: text
WHEN (
(finicity_categorization ->> 'category' :: text) = ANY (
ARRAY ['Streaming Services'::text, 'Home Phone'::text, 'Television'::text, 'Bills & Utilities'::text, 'Utilities'::text, 'Internet / Broadband Charges'::text, 'Mobile Phone'::text]
)
) THEN 'Utilities' :: text
WHEN (
(finicity_categorization ->> 'category' :: text) = ANY (
ARRAY ['Fast Food'::text, 'Food & Dining'::text, 'Restaurants'::text, 'Coffee Shops'::text, 'Alcohol & Bars'::text, 'Groceries'::text]
)
) THEN 'Food and Drink' :: text
WHEN (
(finicity_categorization ->> 'category' :: text) = ANY (
ARRAY ['Auto & Transport'::text, 'Gas & Fuel'::text, 'Auto Insurance'::text]
)
) THEN 'Transportation' :: text
WHEN (
(finicity_categorization ->> 'category' :: text) = ANY (
ARRAY ['Hotel'::text, 'Travel'::text, 'Rental Car & Taxi'::text]
)
) THEN 'Travel' :: text
WHEN (
(finicity_categorization ->> 'category' :: text) = ANY (
ARRAY ['Health Insurance'::text, 'Doctor'::text, 'Pharmacy'::text, 'Eyecare'::text, 'Health & Fitness'::text, 'Personal Care'::text]
)
) THEN 'Health' :: text
WHEN (teller_category = 'income' :: text) THEN 'Income' :: text
WHEN (teller_category = 'home' :: text) THEN 'Home Improvement' :: text
WHEN (
teller_category = ANY (ARRAY ['phone'::text, 'utilities'::text])
) THEN 'Utilities' :: text
WHEN (
teller_category = ANY (
ARRAY ['dining'::text, 'bar'::text, 'groceries'::text]
)
) THEN 'Food and Drink' :: text
WHEN (
teller_category = ANY (
ARRAY ['clothing'::text, 'entertainment'::text, 'shopping'::text, 'electronics'::text, 'software'::text, 'sport'::text]
)
) THEN 'Shopping' :: text
WHEN (
teller_category = ANY (ARRAY ['transportation'::text, 'fuel'::text])
) THEN 'Transportation' :: text
WHEN (
teller_category = ANY (ARRAY ['accommodation'::text, 'transport'::text])
) THEN 'Travel' :: text
WHEN (teller_category = 'health' :: text) THEN 'Health' :: text
WHEN (
teller_category = ANY (
ARRAY ['loan'::text, 'tax'::text, 'insurance'::text, 'office'::text]
)
) THEN 'Other Payments' :: text
ELSE 'Other' :: text
END
)
) STORED;
CREATE
OR REPLACE VIEW transactions_enriched AS (
SELECT
t.id,
t.created_at as "createdAt",
t.updated_at as "updatedAt",
t.name,
t.account_id as "accountId",
t.date,
t.flow,
COALESCE(
t.type_user,
CASE
-- no matching transaction
WHEN t.match_id IS NULL THEN (
CASE
t.flow
WHEN 'INFLOW' THEN (
CASE
a.classification
WHEN 'asset' THEN 'INCOME' :: "TransactionType"
WHEN 'liability' THEN 'PAYMENT' :: "TransactionType"
END
)
WHEN 'OUTFLOW' THEN 'EXPENSE' :: "TransactionType"
END
) -- has matching transaction
ELSE (
CASE
a.classification
WHEN 'asset' THEN 'TRANSFER' :: "TransactionType"
WHEN 'liability' THEN 'PAYMENT' :: "TransactionType"
END
)
END
) AS "type",
t.type_user as "typeUser",
t.amount,
t.currency_code as "currencyCode",
t.pending,
t.merchant_name as "merchantName",
t.category,
t.category_user as "categoryUser",
t.excluded,
t.match_id as "matchId",
COALESCE(ac.user_id, a.user_id) as "userId",
a.classification as "accountClassification",
a.type as "accountType"
FROM
transaction t
inner join account a on a.id = t.account_id
left join account_connection ac on a.account_connection_id = ac.id
);
ALTER TABLE
"transaction" DROP COLUMN "category_old";
ALTER TABLE
"transaction" DROP COLUMN "category_user_old";
COMMIT;

View file

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

View file

@ -0,0 +1 @@
export * as TellerGenerator from './tellerGenerator'

View file

@ -0,0 +1,248 @@
import { faker } from '@faker-js/faker'
import type { TellerTypes } from '../../libs/teller-api/src'
function generateSubType(
type: TellerTypes.AccountTypes
): TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype {
if (type === 'depository') {
return faker.helpers.arrayElement([
'checking',
'savings',
'money_market',
'certificate_of_deposit',
'treasury',
'sweep',
]) as TellerTypes.DepositorySubtypes
} else {
return 'credit_card' as TellerTypes.CreditSubtype
}
}
type GenerateAccountsParams = {
count: number
enrollmentId: string
institutionName: string
institutionId: string
}
export function generateAccounts({
count,
enrollmentId,
institutionName,
institutionId,
}: GenerateAccountsParams) {
const accounts: TellerTypes.Account[] = []
for (let i = 0; i < count; i++) {
const accountId = faker.string.uuid()
const lastFour = faker.finance.creditCardNumber().slice(-4)
const type: TellerTypes.AccountTypes = faker.helpers.arrayElement(['depository', 'credit'])
let subType: TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype
subType = generateSubType(type)
const accountStub = {
enrollment_id: enrollmentId,
links: {
balances: `https://api.teller.io/accounts/${accountId}/balances`,
self: `https://api.teller.io/accounts/${accountId}`,
transactions: `https://api.teller.io/accounts/${accountId}/transactions`,
},
institution: {
name: institutionName,
id: institutionId,
},
name: faker.finance.accountName(),
currency: 'USD',
id: accountId,
last_four: lastFour,
status: faker.helpers.arrayElement(['open', 'closed']) as TellerTypes.AccountStatus,
}
if (faker.datatype.boolean()) {
accounts.push({
...accountStub,
type: 'depository',
subtype: faker.helpers.arrayElement([
'checking',
'savings',
'money_market',
'certificate_of_deposit',
'treasury',
'sweep',
]),
})
} else {
accounts.push({
...accountStub,
type: 'credit',
subtype: 'credit_card',
})
}
}
return accounts
}
export function generateBalance(account_id: string): TellerTypes.AccountBalance {
const amount = faker.finance.amount()
return {
available: amount,
ledger: amount,
links: {
account: `https://api.teller.io/accounts/${account_id}`,
self: `https://api.teller.io/accounts/${account_id}/balances`,
},
account_id,
}
}
type GenerateAccountsWithBalancesParams = {
count: number
enrollmentId: string
institutionName: string
institutionId: string
}
export function generateAccountsWithBalances({
count,
enrollmentId,
institutionName,
institutionId,
}: GenerateAccountsWithBalancesParams): TellerTypes.AccountWithBalances[] {
const accountsWithBalances: TellerTypes.AccountWithBalances[] = []
for (let i = 0; i < count; i++) {
const account = generateAccounts({
count,
enrollmentId,
institutionName,
institutionId,
})[0]
const balance = generateBalance(account.id)
accountsWithBalances.push({
...account,
balance,
})
}
return accountsWithBalances
}
export function generateTransactions(count: number, accountId: string): TellerTypes.Transaction[] {
const transactions: TellerTypes.Transaction[] = []
for (let i = 0; i < count; i++) {
const transactionId = `txn_${faker.string.uuid()}`
const transaction = {
details: {
processing_status: faker.helpers.arrayElement(['complete', 'pending']),
category: faker.helpers.arrayElement([
'accommodation',
'advertising',
'bar',
'charity',
'clothing',
'dining',
'education',
'electronics',
'entertainment',
'fuel',
'general',
'groceries',
'health',
'home',
'income',
'insurance',
'investment',
'loan',
'office',
'phone',
'service',
'shopping',
'software',
'sport',
'tax',
'transport',
'transportation',
'utilities',
]),
counterparty: {
name: faker.company.name(),
type: faker.helpers.arrayElement(['person', 'business']),
},
},
running_balance: null,
description: faker.word.words({ count: { min: 3, max: 10 } }),
id: transactionId,
date: faker.date.recent({ days: 30 }).toISOString().split('T')[0], // recent date in 'YYYY-MM-DD' format
account_id: accountId,
links: {
account: `https://api.teller.io/accounts/${accountId}`,
self: `https://api.teller.io/accounts/${accountId}/transactions/${transactionId}`,
},
amount: faker.finance.amount(),
type: faker.helpers.arrayElement(['transfer', 'deposit', 'withdrawal']),
status: faker.helpers.arrayElement(['pending', 'posted']),
} as TellerTypes.Transaction
transactions.push(transaction)
}
return transactions
}
export function generateEnrollment(): TellerTypes.Enrollment & { institutionId: string } {
const institutionName = faker.company.name()
const institutionId = institutionName.toLowerCase().replace(/\s/g, '_')
return {
accessToken: `token_${faker.string.alphanumeric(15)}`,
user: {
id: `usr_${faker.string.alphanumeric(15)}`,
},
enrollment: {
id: `enr_${faker.string.alphanumeric(15)}`,
institution: {
name: institutionName,
},
},
signatures: [faker.string.alphanumeric(15)],
institutionId,
}
}
type GenerateConnectionsResponse = {
enrollment: TellerTypes.Enrollment & { institutionId: string }
accounts: TellerTypes.Account[]
accountsWithBalances: TellerTypes.AccountWithBalances[]
transactions: TellerTypes.Transaction[]
}
export function generateConnection(): GenerateConnectionsResponse {
const accountsWithBalances: TellerTypes.AccountWithBalances[] = []
const accounts: TellerTypes.Account[] = []
const transactions: TellerTypes.Transaction[] = []
const enrollment = generateEnrollment()
const accountCount: number = faker.number.int({ min: 1, max: 3 })
const enrollmentId = enrollment.enrollment.id
const institutionName = enrollment.enrollment.institution.name
const institutionId = enrollment.institutionId
accountsWithBalances.push(
...generateAccountsWithBalances({
count: accountCount,
enrollmentId,
institutionName,
institutionId,
})
)
for (const account of accountsWithBalances) {
const { balance, ...accountWithoutBalance } = account
accounts.push(accountWithoutBalance)
const transactionsCount: number = faker.number.int({ min: 1, max: 5 })
const generatedTransactions = generateTransactions(transactionsCount, account.id)
transactions.push(...generatedTransactions)
}
return {
enrollment,
accounts,
accountsWithBalances,
transactions,
}
}

View file

@ -405,7 +405,7 @@
},
"test": {
"executor": "@nrwl/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"outputs": ["/coverage/libs/teller-api"],
"options": {
"jestConfig": "libs/teller-api/jest.config.ts",
"passWithNoTests": true

View file

@ -1730,6 +1730,11 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@faker-js/faker@^8.3.1":
version "8.3.1"
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-8.3.1.tgz#7753df0cb88d7649becf984a96dd1bd0a26f43e3"
integrity sha512-FdgpFxY6V6rLZE9mmIBb9hM0xpfvQOSNOLnzolzKwsE1DH+gC7lEKV1p1IbR0lAYyvYd5a4u3qWJzowUkw1bIw==
"@fast-csv/format@^4.3.5":
version "4.3.5"
resolved "https://registry.yarnpkg.com/@fast-csv/format/-/format-4.3.5.tgz#90d83d1b47b6aaf67be70d6118f84f3e12ee1ff3"
@ -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"