1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-08 15:05: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 }),
})
export function closeRedis() {
redis.disconnect()
}
export const queueService = new QueueService(
logger.child({ service: 'QueueService' }),
process.env.NODE_ENV === 'test'

View file

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

View file

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

View file

@ -101,16 +101,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
private async _extractAccounts(accessToken: string) {
const accounts = await this.teller.getAccounts({ accessToken })
const accountsWithBalances = await Promise.all(
accounts.map(async (a) => {
const balance = await this.teller.getAccountBalances({
accountId: a.id,
accessToken,
})
return { ...a, balance }
})
)
return accountsWithBalances
return accounts
}
private _loadAccounts(connection: Connection, { accounts }: Pick<TellerData, 'accounts'>) {
@ -212,7 +203,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
${Prisma.join(
chunk.map((tellerTransaction) => {
const {
id,
id: transactionId,
account_id,
description,
amount,
@ -226,7 +217,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
(SELECT id FROM account WHERE account_connection_id = ${
connection.id
} AND teller_account_id = ${account_id.toString()}),
${id},
${transactionId},
${date}::date,
${description},
${DbUtil.toDecimal(-amount)},

View file

@ -34,7 +34,20 @@ export class TellerApi {
*/
async getAccounts({ accessToken }: AuthenticatedRequest): Promise<GetAccountsResponse> {
return this.get<GetAccountsResponse>(`/accounts`, accessToken)
const accounts = await this.get<GetAccountsResponse>(`/accounts`, accessToken)
const accountsWithBalances = await Promise.all(
accounts.map(async (account) => {
const balance = await this.getAccountBalances({
accountId: account.id,
accessToken,
})
return {
...account,
balance,
}
})
)
return accountsWithBalances
}
/**

View file

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