diff --git a/README.md b/README.md index 52cf4586..257948e0 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ And dozens upon dozens of smaller features. This is the current state of building the app. We're actively working to make this process much more streamlined! -*You'll need Docker installed to run the app locally.* +_You'll need Docker installed to run the app locally._ [Docker Desktop](https://www.docker.com/products/docker-desktop/) is an easy way to get started. First, copy the `.env.example` file to `.env`: diff --git a/apps/client/pages/_app.tsx b/apps/client/pages/_app.tsx index ae4d14db..8b8c1c4a 100644 --- a/apps/client/pages/_app.tsx +++ b/apps/client/pages/_app.tsx @@ -44,7 +44,7 @@ const WithAuth = function ({ children }: PropsWithChildren) { } }, [session, status, router]) - if (session) { + if (session && status === 'authenticated') { return ( diff --git a/apps/server/src/app/__tests__/connection.integration.spec.ts b/apps/server/src/app/__tests__/connection.integration.spec.ts index 7eb81099..a8bfa721 100644 --- a/apps/server/src/app/__tests__/connection.integration.spec.ts +++ b/apps/server/src/app/__tests__/connection.integration.spec.ts @@ -1,21 +1,14 @@ import type { AxiosInstance } from 'axios' -import type { SharedType } from '@maybe-finance/shared' -import type { Prisma, AccountConnection, AccountSyncStatus, User } from '@prisma/client' -import type { ItemRemoveResponse } from 'plaid' +import type { Prisma, AccountConnection, User } from '@prisma/client' +import { AccountConnectionType, AccountSyncStatus } from '@prisma/client' import { startServer, stopServer } from './utils/server' import { getAxiosClient } from './utils/axios' import prisma from '../lib/prisma' -import { TestUtil } from '@maybe-finance/shared' import { InMemoryQueue } from '@maybe-finance/server/shared' -import { default as _plaid } from '../lib/plaid' import nock from 'nock' import { resetUser } from './utils/user' -jest.mock('../middleware/validate-plaid-jwt.ts') -jest.mock('plaid') - -// For TypeScript support -const plaid = jest.mocked(_plaid) +jest.mock('../lib/teller.ts') const authId = '__TEST_USER_ID__' let axios: AxiosInstance @@ -49,13 +42,13 @@ beforeEach(async () => { connectionData = { data: { name: 'Chase Test', - type: 'plaid' as SharedType.AccountConnectionType, - plaidItemId: 'test-plaid-item-server', - plaidInstitutionId: 'ins_3', - plaidAccessToken: - 'U2FsdGVkX1+WMq9lfTS9Zkbgrn41+XT1hvSK5ain/udRPujzjVCAx/lyPG7EumVZA+nVKXPauGwI+d7GZgtqTA9R3iCZNusU6LFPnmFOCE4=', - userId: user!.id, - syncStatus: 'PENDING' as AccountSyncStatus, + type: AccountConnectionType.teller, + tellerEnrollmentId: 'test-teller-item-workers', + tellerInstitutionId: 'chase_test', + tellerAccessToken: + 'U2FsdGVkX1+WMq9lfTS9Zkbgrn41+XT1hvSK5ain/udRPujzjVCAx/lyPG7EumVZA+nVKXPauGwI+d7GZgtqTA9R3iCZNusU6LFPnmFOCE4=', // need correct encoding here + userId: user.id, + syncStatus: AccountSyncStatus.PENDING, }, } @@ -91,16 +84,9 @@ describe('/v1/connections API', () => { }) it('DELETE /:id', async () => { - plaid.itemRemove.mockResolvedValueOnce( - TestUtil.axiosSuccess({ - request_id: 'test request id', - }) - ) - const res = await axios.delete(`/connections/${connection.id}`) expect(res.status).toEqual(200) - expect(plaid.itemRemove).toHaveBeenCalledTimes(1) const res2 = await axios.get(`/connections/${connection.id}`) diff --git a/apps/server/src/app/__tests__/net-worth.integration.spec.ts b/apps/server/src/app/__tests__/net-worth.integration.spec.ts index 5e5f7465..c17129ab 100644 --- a/apps/server/src/app/__tests__/net-worth.integration.spec.ts +++ b/apps/server/src/app/__tests__/net-worth.integration.spec.ts @@ -5,7 +5,6 @@ import { DateTime } from 'luxon' import { PgService } from '@maybe-finance/server/shared' import { AccountQueryService, UserService } from '@maybe-finance/server/features' import { resetUser } from './utils/user' -jest.mock('plaid') const prisma = new PrismaClient() diff --git a/apps/workers/src/app/__tests__/queue.integration.spec.ts b/apps/workers/src/app/__tests__/queue.integration.spec.ts index 07882ee2..2eff8ced 100644 --- a/apps/workers/src/app/__tests__/queue.integration.spec.ts +++ b/apps/workers/src/app/__tests__/queue.integration.spec.ts @@ -1,26 +1,21 @@ // ===================================================== // Keep these imports above the rest to avoid errors // ===================================================== -import type { SharedType } from '@maybe-finance/shared' -import type { AccountsGetResponse, TransactionsGetResponse } from 'plaid' -import type { AccountConnection, User } from '@prisma/client' -import { TestUtil } from '@maybe-finance/shared' -import { PlaidTestData } from '../../../../../tools/test-data' -import { Prisma } from '@prisma/client' +import { TellerGenerator } from 'tools/generators' +import type { User, AccountConnection } from '@prisma/client' +import { AccountConnectionType } from '@prisma/client' import prisma from '../lib/prisma' -import { default as _plaid } from '../lib/plaid' -import nock from 'nock' -import { DateTime } from 'luxon' +import { default as _teller } from '../lib/teller' import { resetUser } from './helpers/user.test-helper' +import { Interval } from 'luxon' // Import the workers process import '../../main' -import { queueService, securityPricingService } from '../lib/di' - -jest.mock('plaid') +import { queueService } from '../lib/di' // For TypeScript support -const plaid = jest.mocked(_plaid) +jest.mock('../lib/teller') +const teller = jest.mocked(_teller) let user: User | null let connection: AccountConnection @@ -30,25 +25,6 @@ if (process.env.IS_VSCODE_DEBUG === 'true') { jest.setTimeout(100000) } -beforeAll(() => { - nock.disableNetConnect() - - nock('https://api.polygon.io') - .get((uri) => uri.includes('v2/aggs/ticker/AAPL/range/1/day')) - .reply(200, PlaidTestData.AAPL) - .persist() - - nock('https://api.polygon.io') - .get((uri) => uri.includes('v2/aggs/ticker/WMT/range/1/day')) - .reply(200, PlaidTestData.WMT) - .persist() - - nock('https://api.polygon.io') - .get((uri) => uri.includes('v2/aggs/ticker/VOO/range/1/day')) - .reply(200, PlaidTestData.VOO) - .persist() -}) - beforeEach(async () => { jest.clearAllMocks() @@ -57,10 +33,10 @@ beforeEach(async () => { connection = await prisma.accountConnection.create({ data: { name: 'Chase Test', - type: 'plaid' as SharedType.AccountConnectionType, - plaidItemId: 'test-plaid-item-workers', - plaidInstitutionId: 'ins_3', - plaidAccessToken: + type: AccountConnectionType.teller, + tellerEnrollmentId: 'test-teller-item-workers', + tellerInstitutionId: 'chase_test', + tellerAccessToken: 'U2FsdGVkX1+WMq9lfTS9Zkbgrn41+XT1hvSK5ain/udRPujzjVCAx/lyPG7EumVZA+nVKXPauGwI+d7GZgtqTA9R3iCZNusU6LFPnmFOCE4=', // need correct encoding here userId: user.id, syncStatus: 'PENDING', @@ -84,7 +60,7 @@ describe('Message queue tests', () => { it('Should handle sync errors', async () => { const syncQueue = queueService.getQueue('sync-account-connection') - plaid.accountsGet.mockRejectedValueOnce('forced error for Jest tests') + teller.getAccounts.mockRejectedValueOnce(new Error('forced error for Jest tests')) await syncQueue.add('sync-connection', { accountConnectionId: connection.id }) @@ -92,7 +68,7 @@ describe('Message queue tests', () => { where: { id: connection.id }, }) - expect(plaid.accountsGet).toHaveBeenCalledTimes(1) + expect(teller.getAccounts).toHaveBeenCalledTimes(1) expect(updatedConnection?.status).toEqual('ERROR') }) @@ -117,28 +93,23 @@ describe('Message queue tests', () => { const syncQueue = queueService.getQueue('sync-account-connection') // Mock will return a basic banking checking account - plaid.accountsGet.mockResolvedValueOnce( - TestUtil.axiosSuccess({ - accounts: [PlaidTestData.checkingAccount], - item: PlaidTestData.item, - request_id: 'bkVE1BHWMAZ9Rnr', - }) as any - ) + const mockAccounts = TellerGenerator.generateAccountsWithBalances({ + count: 1, + institutionId: 'chase_test', + enrollmentId: 'test-teller-item-workers', + institutionName: 'Chase Test', + accountType: 'depository', + accountSubType: 'checking', + }) + teller.getAccounts.mockResolvedValueOnce(mockAccounts) - plaid.transactionsGet.mockResolvedValueOnce( - TestUtil.axiosSuccess({ - accounts: [PlaidTestData.checkingAccount], - transactions: PlaidTestData.checkingTransactions, - item: PlaidTestData.item, - total_transactions: PlaidTestData.checkingTransactions.length, - request_id: '45QSn', - }) as any - ) + const mockTransactions = TellerGenerator.generateTransactions(10, mockAccounts[0].id) + teller.getTransactions.mockResolvedValueOnce(mockTransactions) await syncQueue.add('sync-connection', { accountConnectionId: connection.id }) - expect(plaid.accountsGet).toHaveBeenCalledTimes(1) - expect(plaid.transactionsGet).toHaveBeenCalledTimes(1) + expect(teller.getAccounts).toHaveBeenCalledTimes(1) + expect(teller.getTransactions).toHaveBeenCalledTimes(1) const item = await prisma.accountConnection.findUniqueOrThrow({ where: { id: connection.id }, @@ -146,7 +117,7 @@ describe('Message queue tests', () => { accounts: { include: { balances: { - where: PlaidTestData.testDates.prismaWhereFilter, + where: TellerGenerator.testDates.prismaWhereFilter, orderBy: { date: 'asc' }, }, transactions: true, @@ -162,61 +133,25 @@ describe('Message queue tests', () => { const [account] = item.accounts - expect(account.transactions).toHaveLength(PlaidTestData.checkingTransactions.length) - expect(account.balances.map((b) => b.balance)).toEqual( - [ - 3630, - 5125, - 5125, - 5125, - 5125, - 5125, - 5125, - 5125, - 5125, - 5125, - 5115, - 5115, - 5115, - 5089.45, - 5089.45, - PlaidTestData.checkingAccount.balances.current!, - ].map((v) => new Prisma.Decimal(v)) + const intervalDates = Interval.fromDateTimes( + TellerGenerator.lowerBound, + TellerGenerator.now ) + .splitBy({ day: 1 }) + .map((date: Interval) => date.start.toISODate()) + + const startingBalance = Number(mockAccounts[0].balance.available) + + const balances = TellerGenerator.calculateDailyBalances( + startingBalance, + mockTransactions, + intervalDates + ) + + expect(account.transactions).toHaveLength(10) + expect(account.balances.map((b) => b.balance)).toEqual(balances) expect(account.holdings).toHaveLength(0) expect(account.valuations).toHaveLength(0) expect(account.investmentTransactions).toHaveLength(0) }) - - it('Should sync valid security prices', async () => { - const security = await prisma.security.create({ - data: { - name: 'Walmart Inc.', - symbol: 'WMT', - cusip: '93114210310', - pricingLastSyncedAt: new Date(), - }, - }) - - await securityPricingService.sync(security) - - const prices = await prisma.securityPricing.findMany({ - where: { securityId: security.id }, - orderBy: { date: 'asc' }, - }) - - expect(prices).toHaveLength(PlaidTestData.WMT.results.length) - - expect( - prices.map((p) => ({ - date: DateTime.fromJSDate(p.date, { zone: 'utc' }).toISODate(), - price: p.priceClose.toNumber(), - })) - ).toEqual( - PlaidTestData.WMT.results.map((p) => ({ - date: DateTime.fromMillis(p.t, { zone: 'utc' }).toISODate(), - price: p.c, - })) - ) - }) }) diff --git a/prisma/seed.ts b/prisma/seed.ts index adcfa65d..a8716316 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -8,17 +8,30 @@ const prisma = new PrismaClient() */ async function main() { const institutions: (Pick & { - providers: { provider: Provider; providerId: string; rank?: number }[] + providers: { provider: Provider; providerId: string; logoUrl: string; rank?: number }[] })[] = [ { id: 1, name: 'Capital One', - providers: [{ provider: 'PLAID', providerId: 'ins_9', rank: 1 }], + providers: [ + { + provider: Provider.TELLER, + providerId: 'capital_one', + logoUrl: 'https://teller.io/images/banks/capital_one.jpg', + rank: 1, + }, + ], }, { id: 2, - name: 'Discover Bank', - providers: [{ provider: 'PLAID', providerId: 'ins_33' }], + name: 'Wells Fargo', + providers: [ + { + provider: Provider.TELLER, + providerId: 'wells_fargo', + logoUrl: 'https://teller.io/images/banks/wells_fargo.jpg', + }, + ], }, ] diff --git a/tools/generators/tellerGenerator.ts b/tools/generators/tellerGenerator.ts index 8ee131b4..f0d621ac 100644 --- a/tools/generators/tellerGenerator.ts +++ b/tools/generators/tellerGenerator.ts @@ -1,5 +1,7 @@ import { faker } from '@faker-js/faker' import type { TellerTypes } from '../../libs/teller-api/src' +import type { Prisma } from '@prisma/client' +import { DateTime } from 'luxon' function generateSubType( type: TellerTypes.AccountTypes @@ -23,6 +25,8 @@ type GenerateAccountsParams = { enrollmentId: string institutionName: string institutionId: string + accountType?: TellerTypes.AccountTypes + accountSubType?: TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype } export function generateAccounts({ @@ -30,12 +34,15 @@ export function generateAccounts({ enrollmentId, institutionName, institutionId, + accountType, + accountSubType, }: 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']) + const type: TellerTypes.AccountTypes = + accountType ?? faker.helpers.arrayElement(['depository', 'credit']) let subType: TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype subType = generateSubType(type) @@ -99,6 +106,8 @@ type GenerateAccountsWithBalancesParams = { enrollmentId: string institutionName: string institutionId: string + accountType?: TellerTypes.AccountTypes + accountSubType?: TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype } export function generateAccountsWithBalances({ @@ -106,7 +115,9 @@ export function generateAccountsWithBalances({ enrollmentId, institutionName, institutionId, -}: GenerateAccountsWithBalancesParams): TellerTypes.AccountWithBalances[] { + accountType, + accountSubType, +}: GenerateAccountsWithBalancesParams): TellerTypes.GetAccountsResponse { const accountsWithBalances: TellerTypes.AccountWithBalances[] = [] for (let i = 0; i < count; i++) { const account = generateAccounts({ @@ -114,6 +125,8 @@ export function generateAccountsWithBalances({ enrollmentId, institutionName, institutionId, + accountType, + accountSubType, })[0] const balance = generateBalance(account.id) accountsWithBalances.push({ @@ -170,7 +183,10 @@ export function generateTransactions(count: number, accountId: string): TellerTy 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 + date: faker.date + .between({ from: lowerBound.toJSDate(), to: now.toJSDate() }) + .toISOString() + .split('T')[0], // recent date in 'YYYY-MM-DD' format account_id: accountId, links: { account: `https://api.teller.io/accounts/${accountId}`, @@ -246,3 +262,35 @@ export function generateConnection(): GenerateConnectionsResponse { transactions, } } + +export const now = DateTime.fromISO('2022-01-03', { zone: 'utc' }) + +export const lowerBound = DateTime.fromISO('2021-12-01', { zone: 'utc' }) + +export const testDates = { + now, + lowerBound, + totalDays: now.diff(lowerBound, 'days').days, + prismaWhereFilter: { + date: { + gte: lowerBound.toJSDate(), + lte: now.toJSDate(), + }, + } as Prisma.AccountBalanceWhereInput, +} + +export function calculateDailyBalances(startingBalance, transactions, dateInterval) { + transactions.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + + const balanceChanges = {} + + transactions.forEach((transaction) => { + const date = new Date(transaction.date).toISOString().split('T')[0] + balanceChanges[date] = (balanceChanges[date] || 0) + Number(transaction.amount) + }) + return dateInterval.map((date) => { + return Object.keys(balanceChanges) + .filter((d) => d <= date) + .reduce((acc, d) => acc + balanceChanges[d], startingBalance) + }) +}