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