1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 15:35:22 +02:00
This commit is contained in:
Tyler Myracle 2024-01-17 19:33:48 -06:00
parent 40f424b8fe
commit bce2825fd0
8 changed files with 206 additions and 57 deletions

View file

@ -70,6 +70,10 @@ const redis = new Redis(env.NX_REDIS_URL, {
retryStrategy: ServerUtil.redisRetryStrategy({ maxAttempts: 5 }), retryStrategy: ServerUtil.redisRetryStrategy({ maxAttempts: 5 }),
}) })
export function closeRedis() {
redis.disconnect()
}
export const queueService = new QueueService( export const queueService = new QueueService(
logger.child({ service: 'QueueService' }), logger.child({ service: 'QueueService' }),
process.env.NODE_ENV === 'test' process.env.NODE_ENV === 'test'

View file

@ -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
}
} }

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

@ -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'>) {
@ -212,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,
@ -226,7 +217,7 @@ 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}, ${description},
${DbUtil.toDecimal(-amount)}, ${DbUtil.toDecimal(-amount)},

View file

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

View file

@ -55,7 +55,7 @@ export type AccountWithBalances = Account & {
balance: 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

View file

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

View file

@ -1,5 +1,5 @@
import { faker } from '@faker-js/faker' import { faker } from '@faker-js/faker'
import { TellerTypes } from '../../libs/teller-api/src' import type { TellerTypes } from '../../libs/teller-api/src'
function generateSubType( function generateSubType(
type: TellerTypes.AccountTypes type: TellerTypes.AccountTypes
@ -204,29 +204,45 @@ export function generateEnrollment(): TellerTypes.Enrollment & { institutionId:
} }
} }
export function generateConnections(count: number) { type GenerateConnectionsResponse = {
const enrollments: (TellerTypes.Enrollment & { institutionId: string })[] = [] enrollment: TellerTypes.Enrollment & { institutionId: string }
const accountsWithBalances: TellerTypes.AccountWithBalances[] = [] accounts: TellerTypes.Account[]
const transactions: TellerTypes.Transaction[] = [] accountsWithBalances: TellerTypes.AccountWithBalances[]
for (let i = 0; i < count; i++) { transactions: TellerTypes.Transaction[]
enrollments.push(generateEnrollment()) }
}
enrollments.forEach((enrollment) => { export function generateConnection(): GenerateConnectionsResponse {
const accountCount: number = faker.number.int({ min: 1, max: 5 }) const accountsWithBalances: TellerTypes.AccountWithBalances[] = []
const transactionsCount: number = faker.number.int({ min: 1, max: 50 }) const accounts: TellerTypes.Account[] = []
const enrollmentId = enrollment.enrollment.id const transactions: TellerTypes.Transaction[] = []
const institutionName = enrollment.enrollment.institution.name
const institutionId = enrollment.institutionId const enrollment = generateEnrollment()
accountsWithBalances.push(
...generateAccountsWithBalances({ const accountCount: number = faker.number.int({ min: 1, max: 3 })
count: accountCount,
enrollmentId, const enrollmentId = enrollment.enrollment.id
institutionName, const institutionName = enrollment.enrollment.institution.name
institutionId, const institutionId = enrollment.institutionId
}) accountsWithBalances.push(
) ...generateAccountsWithBalances({
accountsWithBalances.forEach((account) => { count: accountCount,
transactions.push(...generateTransactions(transactionsCount, account.id)) 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,
}
} }