From b34563c60c4976a423eef6190d5fecd49cf24392 Mon Sep 17 00:00:00 2001 From: Tyler Myracle Date: Tue, 16 Jan 2024 12:21:19 -0600 Subject: [PATCH 01/35] add useTeller hook --- libs/client/shared/src/hooks/index.ts | 1 + libs/client/shared/src/hooks/useTeller.ts | 71 +++++++++++++++++++++++ package.json | 1 + yarn.lock | 12 ++++ 4 files changed, 85 insertions(+) create mode 100644 libs/client/shared/src/hooks/useTeller.ts diff --git a/libs/client/shared/src/hooks/index.ts b/libs/client/shared/src/hooks/index.ts index 6481beb2..9410b7f4 100644 --- a/libs/client/shared/src/hooks/index.ts +++ b/libs/client/shared/src/hooks/index.ts @@ -9,5 +9,6 @@ export * from './useQueryParam' export * from './useScreenSize' export * from './useAccountNotifications' export * from './usePlaid' +export * from './useTeller' export * from './useProviderStatus' export * from './useModalManager' diff --git a/libs/client/shared/src/hooks/useTeller.ts b/libs/client/shared/src/hooks/useTeller.ts new file mode 100644 index 00000000..e44838f2 --- /dev/null +++ b/libs/client/shared/src/hooks/useTeller.ts @@ -0,0 +1,71 @@ +import { useState } from 'react' +import toast from 'react-hot-toast' +import * as Sentry from '@sentry/react' +import { useTellerConnect } from 'teller-connect-react' +import { useAccountContext, useUserAccountContext } from '../providers' +import { useLogger } from './useLogger' + +type TellerFailure = { + type: 'payee' | 'payment' + code: 'timeout' | 'error' + message: string +} + +export function useTeller() { + const logger = useLogger() + + const [institutionId, setInstitutionId] = useState(null) + + const { setExpectingAccounts } = useUserAccountContext() + const { setAccountManager } = useAccountContext() + + const tellerConfig = { + applicationId: process.env.NEXT_PUBLIC_TELLER_APP_ID, + institution: institutionId, + environment: process.env.NEXT_PUBLIC_TELLER_ENV, + selectAccount: 'disabled', + onInit: () => { + toast.dismiss(toastId) + logger.debug(`Teller Connect has initialized`) + }, + onSuccess: (enrollment) => { + logger.debug(`User enrolled successfully`, enrollment) + console.log(enrollment) + setExpectingAccounts(true) + }, + onExit: () => { + logger.debug(`Teller Connect exited`) + }, + onFailure: (failure: TellerFailure) => { + 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, + }, + }) + }, + } + + const { open, ready } = useTellerConnect(tellerConfig) + + useEffect(() => { + if (ready) { + open() + + if (selectAccount === 'disabled') { + setAccountManager({ view: 'idle' }) + } + } + }, [ready, open, setAccountManager]) + + return { + openTeller: async (institutionId: string) => { + toast('Initializing Teller...', { duration: 2_000 }) + setInstitutionId(institutionId) + }, + ready, + } +} diff --git a/package.json b/package.json index 96c52336..6b62db4a 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,7 @@ "stripe": "^10.17.0", "superjson": "^1.11.0", "tailwindcss": "3.2.4", + "teller-connect-react": "^0.1.0", "tslib": "^2.3.0", "uuid": "^9.0.0", "winston": "^3.8.2", diff --git a/yarn.lock b/yarn.lock index e301ab27..1fb86738 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16600,6 +16600,11 @@ react-script-hook@^1.6.0: resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.6.0.tgz#6a44ff5e65113cb29252eadad1b8306f5fe0c626" integrity sha512-aJm72XGWV+wJTKiqHmAaTNC/JQZV/Drv6A1kd1VQlzhzAXLqtBRBeTt3iTESImGe5TaBDHUOUeaGNw4v+7bqDw== +react-script-hook@^1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.7.2.tgz#ec130d67f9a25fcde57fbfd1faa87e5b97521948" + integrity sha512-fhyCEfXb94fag34UPRF0zry1XGwmVY+79iibWwTqAoOiCzYJQOYTiWJ7CnqglA9tMSV8g45cQpHCMcBwr7dwhA== + react-shallow-renderer@^16.15.0: version "16.15.0" resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz#48fb2cf9b23d23cde96708fe5273a7d3446f4457" @@ -18532,6 +18537,13 @@ telejson@^6.0.8: lodash "^4.17.21" memoizerific "^1.11.3" +teller-connect-react@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/teller-connect-react/-/teller-connect-react-0.1.0.tgz#b3bae24f4410d622eb8c88c7668adb003eb7bfd7" + integrity sha512-ZI+OULCsuo/v1qetpjepOgM7TyIzwnMVE/54IruOPguQtJ/Ui3C1ax3wUb65AKZDyVQ7ZyjA+8ypT/yMYD9bIQ== + dependencies: + react-script-hook "^1.7.2" + terminal-link@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" From 63ec1492959cca3972d13723084bb39327200c30 Mon Sep 17 00:00:00 2001 From: Dan Malone Date: Tue, 16 Jan 2024 18:23:44 +0000 Subject: [PATCH 02/35] Update README.md - add docker desktop link Signed-off-by: Dan Malone --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a3c8eca8..3398d4f6 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ And dozens upon dozens of smaller features. This is the current state of building the app. You'll hit errors, which we're working to resolve (and certainly welcome PRs to help with that). -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`: From 27f810c2305cc6da5d0c7f53ab4b4ab10cbc2076 Mon Sep 17 00:00:00 2001 From: David Neuman Date: Tue, 16 Jan 2024 14:41:36 -0500 Subject: [PATCH 03/35] Add ngrok instructions --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index a3c8eca8..7ca30122 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,19 @@ yarn prisma:seed yarn dev ``` +## Set Up Ngrok + +External data providers require HTTPS/SSL webhook URLs for sending data. + +To test this locally/during development, you will need to setup `ngrok`. + +1. Visit [ngrok.com](https://ngrok.com/) +2. Create a free account +3. Visit [this page](https://dashboard.ngrok.com/get-started/your-authtoken) to access your auth token +4. Paste it into your `.env` file: `NGROK_AUTH_TOKEN={your_auth_token}` + +That's it! As long as you run the project locally using `docker` with `yarn dev:services:all` you'll be good to go. + ## Contributing To contribute, please see our [contribution guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md). From 2b24582affa670666a37a4fa97e4f3a5d4feaad3 Mon Sep 17 00:00:00 2001 From: David Neuman Date: Tue, 16 Jan 2024 14:58:59 -0500 Subject: [PATCH 04/35] Add instructions for adding static domain from Ngrok Add env variable for static domain to docker compose file --- .env.example | 3 ++- README.md | 10 +++++++++- docker-compose.yml | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index abd0167b..8fa77cdc 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ NX_NEXTAUTH_URL=http://localhost:4200 # We use ngrok to expose a local development environment to the internet # You can sign up for a free account and get an API key at https://ngrok.com NGROK_AUTH_TOKEN= +NGROK_DOMAIN= ######################################################################## # DATABASE @@ -57,4 +58,4 @@ NX_POSTMARK_API_TOKEN= ######################################################################## NX_PLAID_SECRET= NX_FINICITY_APP_KEY= -NX_FINICITY_PARTNER_SECRET= \ No newline at end of file +NX_FINICITY_PARTNER_SECRET= diff --git a/README.md b/README.md index 7ca30122..5709998a 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,15 @@ To test this locally/during development, you will need to setup `ngrok`. 1. Visit [ngrok.com](https://ngrok.com/) 2. Create a free account 3. Visit [this page](https://dashboard.ngrok.com/get-started/your-authtoken) to access your auth token -4. Paste it into your `.env` file: `NGROK_AUTH_TOKEN={your_auth_token}` +4. Paste it into your `.env` file: `NGROK_AUTH_TOKEN=your_auth_token` + +You should claim your free static domain to avoid needing to change the URL each time you start/stop the server. + +To do so: + +1. Visit the [domains](https://dashboard.ngrok.com/cloud-edge/domains) page +2. Click on Create Domain +3. Copy the domain and paste it into your `.env` file: `NGROK_DOMAIN=your_domain` That's it! As long as you run the project locally using `docker` with `yarn dev:services:all` you'll be good to go. diff --git a/docker-compose.yml b/docker-compose.yml index 8593af32..e0656e03 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,7 +31,7 @@ services: ports: - 4551:4551 environment: - - DOMAIN=host.docker.internal + - DOMAIN=${NGROK_DOMAIN} - PORT=3333 - AUTH_TOKEN=${NGROK_AUTH_TOKEN} - DEBUG=true From 76ee026db79a94f62fe2b5f1f4763e5d77376a58 Mon Sep 17 00:00:00 2001 From: Enes Kaya Date: Tue, 16 Jan 2024 21:12:18 +0100 Subject: [PATCH 05/35] Set 'typescript.tsdk' version in VSCode settings --- .vscode/settings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index b76293a8..c5d00b83 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,6 @@ }, "[html]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "typescript.tsdk": "node_modules/typescript/lib" } From b67354708b02411a3dcc37eef6406273bb049bac Mon Sep 17 00:00:00 2001 From: Silver343 <51054165+Silver343@users.noreply.github.com> Date: Tue, 16 Jan 2024 20:12:29 +0000 Subject: [PATCH 06/35] Add Country select to real estate forms --- apps/e2e/src/e2e/accounts.cy.ts | 2 ++ .../accounts-manager/property/AddProperty.tsx | 3 +++ .../property/EditProperty.tsx | 3 ++- .../property/PropertyForm.tsx | 27 ++++++++++++++++--- .../src/providers/AccountContextProvider.tsx | 1 + 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/e2e/src/e2e/accounts.cy.ts b/apps/e2e/src/e2e/accounts.cy.ts index de33a9ea..6eba3db1 100644 --- a/apps/e2e/src/e2e/accounts.cy.ts +++ b/apps/e2e/src/e2e/accounts.cy.ts @@ -149,6 +149,7 @@ describe('Accounts', () => { cy.contains('h4', 'Add real estate') // Details + cy.get('input[name="country"]').select('GB') cy.get('input[name="line1"]').focus().type('123 Example St') cy.get('input[name="city"]').type('New York') cy.get('input[name="state"]').type('NY') @@ -187,6 +188,7 @@ describe('Accounts', () => { openEditAccountModal() cy.getByTestId('property-form').within(() => { + cy.get('input[name="country]').should('have.value', 'GB').clear().select('FR') cy.get('input[name="line1"]') .should('have.value', '123 Example St') .clear() diff --git a/libs/client/features/src/accounts-manager/property/AddProperty.tsx b/libs/client/features/src/accounts-manager/property/AddProperty.tsx index 880b6915..3d8d2fd6 100644 --- a/libs/client/features/src/accounts-manager/property/AddProperty.tsx +++ b/libs/client/features/src/accounts-manager/property/AddProperty.tsx @@ -19,6 +19,7 @@ export function AddProperty({ defaultValues }: { defaultValues: Partial { + onSubmit={async ({ line1, city, state, country, zip, ...rest }) => { await updateAccount.mutateAsync({ id: account.id, data: { @@ -30,6 +30,7 @@ export function EditProperty({ account }: { account: SharedType.AccountDetail }) line1, city, state, + country, zip, }, }, diff --git a/libs/client/features/src/accounts-manager/property/PropertyForm.tsx b/libs/client/features/src/accounts-manager/property/PropertyForm.tsx index 7506c670..eec44819 100644 --- a/libs/client/features/src/accounts-manager/property/PropertyForm.tsx +++ b/libs/client/features/src/accounts-manager/property/PropertyForm.tsx @@ -1,7 +1,7 @@ import type { CreatePropertyFields, UpdatePropertyFields } from '@maybe-finance/client/shared' -import { Button, Input } from '@maybe-finance/design-system' -import { DateUtil } from '@maybe-finance/shared' -import { useForm } from 'react-hook-form' +import { Button, Input, Listbox } from '@maybe-finance/design-system' +import { DateUtil, Geo } from '@maybe-finance/shared' +import { Controller, useForm } from 'react-hook-form' import { AccountValuationFormFields } from '../AccountValuationFormFields' type Props = @@ -36,7 +36,26 @@ export default function PropertyForm({ mode, defaultValues, onSubmit }: Props) {
Location
- + ( + + + {Geo.countries.find((c) => c.code === field.value)?.name || + 'Select'} + + + {Geo.countries.map((country) => ( + + {country.name} + + ))} + + + )} + /> Date: Tue, 16 Jan 2024 15:18:21 -0600 Subject: [PATCH 07/35] teller front end progress --- apps/server/src/app/lib/endpoint.ts | 1 + apps/workers/src/app/lib/di.ts | 1 + apps/workers/src/main.ts | 13 ++++ .../accounts-manager/AccountTypeSelector.tsx | 38 +++++++++- libs/client/shared/src/hooks/index.ts | 1 - libs/client/shared/src/hooks/useTeller.ts | 71 ------------------- .../src/providers/AccountContextProvider.tsx | 1 + libs/client/shared/src/utils/index.ts | 1 + libs/client/shared/src/utils/teller-utils.ts | 39 ++++++++++ .../src/institution/institution.service.ts | 2 +- .../src/providers/teller/teller.service.ts | 19 ++--- .../shared/src/services/queue.service.ts | 2 +- libs/teller-api/src/teller-api.ts | 4 +- libs/teller-api/src/types/institutions.ts | 4 +- .../migration.sql | 2 + prisma/schema.prisma | 1 + 16 files changed, 109 insertions(+), 91 deletions(-) delete mode 100644 libs/client/shared/src/hooks/useTeller.ts create mode 100644 libs/client/shared/src/utils/teller-utils.ts create mode 100644 prisma/migrations/20240116185600_add_teller_provider/migration.sql diff --git a/apps/server/src/app/lib/endpoint.ts b/apps/server/src/app/lib/endpoint.ts index 8e01b00d..f6340672 100644 --- a/apps/server/src/app/lib/endpoint.ts +++ b/apps/server/src/app/lib/endpoint.ts @@ -240,6 +240,7 @@ const userService = new UserService( const institutionProviderFactory = new InstitutionProviderFactory({ PLAID: plaidService, FINICITY: finicityService, + TELLER: tellerService, }) const institutionService: IInstitutionService = new InstitutionService( diff --git a/apps/workers/src/app/lib/di.ts b/apps/workers/src/app/lib/di.ts index 987ebedd..9e6d68c5 100644 --- a/apps/workers/src/app/lib/di.ts +++ b/apps/workers/src/app/lib/di.ts @@ -259,6 +259,7 @@ export const securityPricingProcessor: ISecurityPricingProcessor = new SecurityP const institutionProviderFactory = new InstitutionProviderFactory({ PLAID: plaidService, FINICITY: finicityService, + TELLER: tellerService, }) export const institutionService: IInstitutionService = new InstitutionService( diff --git a/apps/workers/src/main.ts b/apps/workers/src/main.ts index 35c16cdd..7db31bb0 100644 --- a/apps/workers/src/main.ts +++ b/apps/workers/src/main.ts @@ -115,6 +115,11 @@ syncInstitutionQueue.process( async () => await institutionService.sync('FINICITY') ) +syncInstitutionQueue.process( + 'sync-teller-institutions', + async () => await institutionService.sync('TELLER') +) + syncInstitutionQueue.add( 'sync-plaid-institutions', {}, @@ -131,6 +136,14 @@ syncInstitutionQueue.add( } ) +syncInstitutionQueue.add( + 'sync-teller-institutions', + {}, + { + repeat: { cron: '* */24 * * *' }, // Run every 24 hours + } +) + /** * send-email queue */ diff --git a/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx b/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx index 2b7c81f8..d3637783 100644 --- a/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx +++ b/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useMemo } from 'react' import { RiFolderLine, RiHandCoinLine, RiLockLine, RiSearchLine } from 'react-icons/ri' import maxBy from 'lodash/maxBy' import { @@ -8,11 +8,13 @@ import { usePlaid, useFinicity, } from '@maybe-finance/client/shared' - import { Input } from '@maybe-finance/design-system' import InstitutionGrid from './InstitutionGrid' import { AccountTypeGrid } from './AccountTypeGrid' import InstitutionList, { MIN_QUERY_LENGTH } from './InstitutionList' +import { useLogger } from '@maybe-finance/client/shared' +import { BrowserUtil } from '@maybe-finance/client/shared' +import { useTellerConnect, type TellerConnectOptions } from 'teller-connect-react' const SEARCH_DEBOUNCE_MS = 300 @@ -23,18 +25,27 @@ export default function AccountTypeSelector({ view: string onViewChange: (view: string) => void }) { + const logger = useLogger() const { setAccountManager } = useAccountContext() const [searchQuery, setSearchQuery] = useState('') const debouncedSearchQuery = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS) + const [institutionId, setInstitutionId] = useState(undefined) + const showInstitutionList = searchQuery.length >= MIN_QUERY_LENGTH && debouncedSearchQuery.length >= MIN_QUERY_LENGTH && view !== 'manual' + const config = useMemo( + () => BrowserUtil.getTellerConfig(logger, institutionId), + [logger, institutionId] + ) as TellerConnectOptions + const { openPlaid } = usePlaid() const { openFinicity } = useFinicity() + const { open: openTeller } = useTellerConnect(config) const inputRef = useRef(null) @@ -44,6 +55,15 @@ export default function AccountTypeSelector({ } }, []) + const configRef = useRef(null) + + useEffect(() => { + if (institutionId) { + configRef.current = BrowserUtil.getTellerConfig(logger, institutionId) + openTeller() + } + }, [institutionId, logger, openTeller]) + return (
{/* Search */} @@ -68,6 +88,8 @@ export default function AccountTypeSelector({ if (!providerInstitution) { alert('No provider found for institution') return + } else { + setInstitutionId(providerInstitution.providerId) } switch (providerInstitution.provider) { @@ -77,6 +99,9 @@ export default function AccountTypeSelector({ case 'FINICITY': openFinicity(providerInstitution.providerId) break + case 'TELLER': + openTeller() + break default: break } @@ -142,7 +167,11 @@ export default function AccountTypeSelector({ return } - if (!data) return + if (!data) { + return + } else { + setInstitutionId(data.providerId) + } switch (data.provider) { case 'PLAID': @@ -151,6 +180,9 @@ export default function AccountTypeSelector({ case 'FINICITY': openFinicity(data.providerId) break + case 'TELLER': + openTeller() + break default: break } diff --git a/libs/client/shared/src/hooks/index.ts b/libs/client/shared/src/hooks/index.ts index 9410b7f4..6481beb2 100644 --- a/libs/client/shared/src/hooks/index.ts +++ b/libs/client/shared/src/hooks/index.ts @@ -9,6 +9,5 @@ export * from './useQueryParam' export * from './useScreenSize' export * from './useAccountNotifications' export * from './usePlaid' -export * from './useTeller' export * from './useProviderStatus' export * from './useModalManager' diff --git a/libs/client/shared/src/hooks/useTeller.ts b/libs/client/shared/src/hooks/useTeller.ts deleted file mode 100644 index e44838f2..00000000 --- a/libs/client/shared/src/hooks/useTeller.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useState } from 'react' -import toast from 'react-hot-toast' -import * as Sentry from '@sentry/react' -import { useTellerConnect } from 'teller-connect-react' -import { useAccountContext, useUserAccountContext } from '../providers' -import { useLogger } from './useLogger' - -type TellerFailure = { - type: 'payee' | 'payment' - code: 'timeout' | 'error' - message: string -} - -export function useTeller() { - const logger = useLogger() - - const [institutionId, setInstitutionId] = useState(null) - - const { setExpectingAccounts } = useUserAccountContext() - const { setAccountManager } = useAccountContext() - - const tellerConfig = { - applicationId: process.env.NEXT_PUBLIC_TELLER_APP_ID, - institution: institutionId, - environment: process.env.NEXT_PUBLIC_TELLER_ENV, - selectAccount: 'disabled', - onInit: () => { - toast.dismiss(toastId) - logger.debug(`Teller Connect has initialized`) - }, - onSuccess: (enrollment) => { - logger.debug(`User enrolled successfully`, enrollment) - console.log(enrollment) - setExpectingAccounts(true) - }, - onExit: () => { - logger.debug(`Teller Connect exited`) - }, - onFailure: (failure: TellerFailure) => { - 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, - }, - }) - }, - } - - const { open, ready } = useTellerConnect(tellerConfig) - - useEffect(() => { - if (ready) { - open() - - if (selectAccount === 'disabled') { - setAccountManager({ view: 'idle' }) - } - } - }, [ready, open, setAccountManager]) - - return { - openTeller: async (institutionId: string) => { - toast('Initializing Teller...', { duration: 2_000 }) - setInstitutionId(institutionId) - }, - ready, - } -} diff --git a/libs/client/shared/src/providers/AccountContextProvider.tsx b/libs/client/shared/src/providers/AccountContextProvider.tsx index 510a4690..454533ca 100644 --- a/libs/client/shared/src/providers/AccountContextProvider.tsx +++ b/libs/client/shared/src/providers/AccountContextProvider.tsx @@ -48,6 +48,7 @@ type AccountManager = | { view: 'idle' } | { view: 'add-plaid'; linkToken: string } | { view: 'add-finicity' } + | { view: 'add-teller' } | { view: 'add-account' } | { view: 'add-property'; defaultValues: Partial } | { view: 'add-vehicle'; defaultValues: Partial } diff --git a/libs/client/shared/src/utils/index.ts b/libs/client/shared/src/utils/index.ts index a1ac6e57..90694d3a 100644 --- a/libs/client/shared/src/utils/index.ts +++ b/libs/client/shared/src/utils/index.ts @@ -2,3 +2,4 @@ export * from './image-loaders' export * from './browser-utils' export * from './account-utils' export * from './form-utils' +export * from './teller-utils' diff --git a/libs/client/shared/src/utils/teller-utils.ts b/libs/client/shared/src/utils/teller-utils.ts new file mode 100644 index 00000000..4265ae84 --- /dev/null +++ b/libs/client/shared/src/utils/teller-utils.ts @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/react' +import type { Logger } from '../providers/LogProvider' +import type { + TellerConnectEnrollment, + TellerConnectFailure, + TellerConnectOptions, +} from 'teller-connect-react' + +type TellerEnvironment = 'sandbox' | 'development' | 'production' | undefined +type TellerAccountSelection = 'disabled' | 'single' | 'multiple' | undefined + +export const getTellerConfig = (logger: Logger, institutionId: string | undefined) => { + 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, + ...(institutionId !== undefined ? { institution: institutionId } : {}), + onInit: () => { + logger.debug(`Teller Connect has initialized`) + }, + onSuccess: (enrollment: TellerConnectEnrollment) => { + logger.debug(`User enrolled successfully`, enrollment) + }, + 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 +} diff --git a/libs/server/features/src/institution/institution.service.ts b/libs/server/features/src/institution/institution.service.ts index 32cfeebd..ed76de28 100644 --- a/libs/server/features/src/institution/institution.service.ts +++ b/libs/server/features/src/institution/institution.service.ts @@ -271,7 +271,7 @@ export class InstitutionService implements IInstitutionService { provider_institution pi SET institution_id = i.id, - rank = (CASE WHEN pi.provider = 'PLAID' THEN 1 ELSE 0 END) + rank = (CASE WHEN pi.provider = 'TELLER' THEN 1 ELSE 0 END) FROM duplicates d INNER JOIN institutions i ON i.name = d.name AND i.url = d.url diff --git a/libs/server/features/src/providers/teller/teller.service.ts b/libs/server/features/src/providers/teller/teller.service.ts index e884639f..a5847041 100644 --- a/libs/server/features/src/providers/teller/teller.service.ts +++ b/libs/server/features/src/providers/teller/teller.service.ts @@ -79,7 +79,7 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr async getInstitutions() { const tellerInstitutions = await SharedUtil.paginate({ - pageSize: 500, + pageSize: 10000, delay: process.env.NODE_ENV !== 'production' ? { @@ -87,20 +87,20 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr milliseconds: 7_000, // Sandbox rate limited at 10 calls / minute } : undefined, - fetchData: (offset, count) => + fetchData: () => SharedUtil.withRetry( () => this.teller.getInstitutions().then((data) => { this.logger.debug( - `paginated teller fetch inst=${data.institutions.length} (total=${data.institutions.length} offset=${offset} count=${count})` + `teller fetch inst=${data.length} (total=${data.length})` ) - return data.institutions + return data }), { maxRetries: 3, onError: (error, attempt) => { this.logger.error( - `Teller fetch institutions request failed attempt=${attempt} offset=${offset} count=${count}`, + `Teller fetch institutions request failed attempt=${attempt}`, { error: ErrorUtil.parseError(error) } ) @@ -115,10 +115,11 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr return { providerId: id, name, - url: undefined, - logo: `https://teller.io/images/banks/${id}.jpg}`, - primaryColor: undefined, - oauth: undefined, + url: null, + logo: null, + logoUrl: `https://teller.io/images/banks/${id}.jpg`, + primaryColor: null, + oauth: false, data: tellerInstitution, } }) diff --git a/libs/server/shared/src/services/queue.service.ts b/libs/server/shared/src/services/queue.service.ts index 319fef92..c0c1d8c7 100644 --- a/libs/server/shared/src/services/queue.service.ts +++ b/libs/server/shared/src/services/queue.service.ts @@ -70,7 +70,7 @@ export type SyncSecurityQueue = IQueue export type SyncInstitutionQueue = IQueue< {}, - 'sync-finicity-institutions' | 'sync-plaid-institutions' + 'sync-finicity-institutions' | 'sync-plaid-institutions' | 'sync-teller-institutions' > export type SendEmailQueue = IQueue diff --git a/libs/teller-api/src/teller-api.ts b/libs/teller-api/src/teller-api.ts index ca3a180a..16b982f7 100644 --- a/libs/teller-api/src/teller-api.ts +++ b/libs/teller-api/src/teller-api.ts @@ -137,8 +137,8 @@ export class TellerApi { } private async getApi(accessToken: string): Promise { - const cert = fs.readFileSync('../../../certs/teller-certificate.pem', 'utf8') - const key = fs.readFileSync('../../../certs/teller-private-key.pem', 'utf8') + const cert = fs.readFileSync('./certs/certificate.pem', 'utf8') + const key = fs.readFileSync('./certs/private_key.pem', 'utf8') const agent = new https.Agent({ cert, diff --git a/libs/teller-api/src/types/institutions.ts b/libs/teller-api/src/types/institutions.ts index 6f243375..3a593e14 100644 --- a/libs/teller-api/src/types/institutions.ts +++ b/libs/teller-api/src/types/institutions.ts @@ -9,6 +9,4 @@ export type Institution = { type Capability = 'detail' | 'balance' | 'transaction' | 'identity' -export type GetInstitutionsResponse = { - institutions: Institution[] -} +export type GetInstitutionsResponse = Institution[] diff --git a/prisma/migrations/20240116185600_add_teller_provider/migration.sql b/prisma/migrations/20240116185600_add_teller_provider/migration.sql new file mode 100644 index 00000000..20c526f2 --- /dev/null +++ b/prisma/migrations/20240116185600_add_teller_provider/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "Provider" ADD VALUE 'TELLER'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 007cad61..918f1349 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -493,6 +493,7 @@ model Institution { enum Provider { PLAID FINICITY + TELLER } model ProviderInstitution { From 97dc37fad191189ee15f92e44d2ed132817d4fb7 Mon Sep 17 00:00:00 2001 From: Tyler Myracle Date: Tue, 16 Jan 2024 18:29:00 -0600 Subject: [PATCH 08/35] more progress on Teller --- apps/server/src/app/app.ts | 2 + apps/server/src/app/routes/index.ts | 1 + apps/server/src/app/routes/teller.router.ts | 45 +++++ .../accounts-manager/AccountTypeSelector.tsx | 33 +--- libs/client/shared/src/api/index.ts | 1 + libs/client/shared/src/api/useTellerApi.ts | 63 ++++++ libs/client/shared/src/hooks/index.ts | 1 + libs/client/shared/src/hooks/useTeller.ts | 180 ++++++++++++++++++ libs/client/shared/src/utils/index.ts | 1 - libs/client/shared/src/utils/teller-utils.ts | 39 ---- .../src/providers/teller/teller.service.ts | 69 ++++++- libs/teller-api/src/teller-api.ts | 21 +- libs/teller-api/src/types/enrollment.ts | 13 ++ libs/teller-api/src/types/index.ts | 1 + package.json | 1 + .../migration.sql | 9 + prisma/schema.prisma | 2 +- 17 files changed, 396 insertions(+), 86 deletions(-) create mode 100644 apps/server/src/app/routes/teller.router.ts create mode 100644 libs/client/shared/src/api/useTellerApi.ts create mode 100644 libs/client/shared/src/hooks/useTeller.ts delete mode 100644 libs/client/shared/src/utils/teller-utils.ts create mode 100644 libs/teller-api/src/types/enrollment.ts create mode 100644 prisma/migrations/20240116224800_add_enrollment_id_for_teller/migration.sql diff --git a/apps/server/src/app/app.ts b/apps/server/src/app/app.ts index c495a30d..06c0870c 100644 --- a/apps/server/src/app/app.ts +++ b/apps/server/src/app/app.ts @@ -36,6 +36,7 @@ import { valuationsRouter, institutionsRouter, finicityRouter, + tellerRouter, transactionsRouter, holdingsRouter, securitiesRouter, @@ -156,6 +157,7 @@ app.use('/v1/users', usersRouter) app.use('/v1/e2e', e2eRouter) app.use('/v1/plaid', plaidRouter) app.use('/v1/finicity', finicityRouter) +app.use('/v1/teller', tellerRouter) app.use('/v1/accounts', accountsRouter) app.use('/v1/account-rollup', accountRollupRouter) app.use('/v1/connections', connectionsRouter) diff --git a/apps/server/src/app/routes/index.ts b/apps/server/src/app/routes/index.ts index 135a9a24..40eb5d16 100644 --- a/apps/server/src/app/routes/index.ts +++ b/apps/server/src/app/routes/index.ts @@ -5,6 +5,7 @@ export { default as usersRouter } from './users.router' export { default as webhooksRouter } from './webhooks.router' export { default as plaidRouter } from './plaid.router' export { default as finicityRouter } from './finicity.router' +export { default as tellerRouter } from './teller.router' export { default as valuationsRouter } from './valuations.router' export { default as institutionsRouter } from './institutions.router' export { default as transactionsRouter } from './transactions.router' diff --git a/apps/server/src/app/routes/teller.router.ts b/apps/server/src/app/routes/teller.router.ts new file mode 100644 index 00000000..c802ff70 --- /dev/null +++ b/apps/server/src/app/routes/teller.router.ts @@ -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 diff --git a/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx b/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx index d3637783..d3be9221 100644 --- a/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx +++ b/libs/client/features/src/accounts-manager/AccountTypeSelector.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useMemo } from 'react' +import { useState, useRef, useEffect } from 'react' import { RiFolderLine, RiHandCoinLine, RiLockLine, RiSearchLine } from 'react-icons/ri' import maxBy from 'lodash/maxBy' import { @@ -7,14 +7,14 @@ import { useDebounce, usePlaid, useFinicity, + useTellerConfig, + useTellerConnect, } from '@maybe-finance/client/shared' import { Input } from '@maybe-finance/design-system' import InstitutionGrid from './InstitutionGrid' import { AccountTypeGrid } from './AccountTypeGrid' import InstitutionList, { MIN_QUERY_LENGTH } from './InstitutionList' import { useLogger } from '@maybe-finance/client/shared' -import { BrowserUtil } from '@maybe-finance/client/shared' -import { useTellerConnect, type TellerConnectOptions } from 'teller-connect-react' const SEARCH_DEBOUNCE_MS = 300 @@ -31,21 +31,16 @@ export default function AccountTypeSelector({ const [searchQuery, setSearchQuery] = useState('') const debouncedSearchQuery = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS) - const [institutionId, setInstitutionId] = useState(undefined) - const showInstitutionList = searchQuery.length >= MIN_QUERY_LENGTH && debouncedSearchQuery.length >= MIN_QUERY_LENGTH && view !== 'manual' - const config = useMemo( - () => BrowserUtil.getTellerConfig(logger, institutionId), - [logger, institutionId] - ) as TellerConnectOptions + const config = useTellerConfig(logger) const { openPlaid } = usePlaid() const { openFinicity } = useFinicity() - const { open: openTeller } = useTellerConnect(config) + const { open: openTeller } = useTellerConnect(config, logger) const inputRef = useRef(null) @@ -55,15 +50,6 @@ export default function AccountTypeSelector({ } }, []) - const configRef = useRef(null) - - useEffect(() => { - if (institutionId) { - configRef.current = BrowserUtil.getTellerConfig(logger, institutionId) - openTeller() - } - }, [institutionId, logger, openTeller]) - return (
{/* Search */} @@ -88,8 +74,6 @@ export default function AccountTypeSelector({ if (!providerInstitution) { alert('No provider found for institution') return - } else { - setInstitutionId(providerInstitution.providerId) } switch (providerInstitution.provider) { @@ -100,7 +84,7 @@ export default function AccountTypeSelector({ openFinicity(providerInstitution.providerId) break case 'TELLER': - openTeller() + openTeller(providerInstitution.providerId) break default: break @@ -163,14 +147,11 @@ export default function AccountTypeSelector({ categoryUser: 'crypto', }, }) - return } if (!data) { return - } else { - setInstitutionId(data.providerId) } switch (data.provider) { @@ -181,7 +162,7 @@ export default function AccountTypeSelector({ openFinicity(data.providerId) break case 'TELLER': - openTeller() + openTeller(data.providerId) break default: break diff --git a/libs/client/shared/src/api/index.ts b/libs/client/shared/src/api/index.ts index 6fe77f31..c03040f8 100644 --- a/libs/client/shared/src/api/index.ts +++ b/libs/client/shared/src/api/index.ts @@ -5,6 +5,7 @@ export * from './useFinicityApi' export * from './useInstitutionApi' export * from './useUserApi' export * from './usePlaidApi' +export * from './useTellerApi' export * from './useValuationApi' export * from './useTransactionApi' export * from './useHoldingApi' diff --git a/libs/client/shared/src/api/useTellerApi.ts b/libs/client/shared/src/api/useTellerApi.ts new file mode 100644 index 00000000..2a323da7 --- /dev/null +++ b/libs/client/shared/src/api/useTellerApi.ts @@ -0,0 +1,63 @@ +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 { invalidateAccountQueries } from '../utils' +import type { AxiosInstance } from 'axios' +import type { TellerTypes } from '@maybe-finance/teller-api' + +type TellerInstitution = { + name: string + id: string +} + +const TellerApi = (axios: AxiosInstance) => ({ + async handleEnrollment(input: { + institution: TellerInstitution + enrollment: TellerTypes.Enrollment + }) { + const { data } = await axios.post( + '/teller/handle-enrollment', + input + ) + return data + }, +}) + +export function useTellerApi() { + const queryClient = useQueryClient() + const { axios } = useAxiosWithAuth() + const api = useMemo(() => TellerApi(axios), [axios]) + + const addConnectionToState = (connection: SharedType.AccountConnection) => { + const accountsData = queryClient.getQueryData(['accounts']) + if (!accountsData) + queryClient.setQueryData(['accounts'], { + connections: [{ ...connection, accounts: [] }], + accounts: [], + }) + else { + const { connections, ...rest } = accountsData + queryClient.setQueryData(['accounts'], { + connections: [...connections, { ...connection, accounts: [] }], + ...rest, + }) + } + } + + const useHandleEnrollment = () => + useMutation(api.handleEnrollment, { + onSuccess: (_connection) => { + addConnectionToState(_connection) + toast.success(`Account connection added!`) + }, + onSettled: () => { + invalidateAccountQueries(queryClient, false) + }, + }) + + return { + useHandleEnrollment, + } +} diff --git a/libs/client/shared/src/hooks/index.ts b/libs/client/shared/src/hooks/index.ts index 6481beb2..9410b7f4 100644 --- a/libs/client/shared/src/hooks/index.ts +++ b/libs/client/shared/src/hooks/index.ts @@ -9,5 +9,6 @@ export * from './useQueryParam' export * from './useScreenSize' export * from './useAccountNotifications' export * from './usePlaid' +export * from './useTeller' export * from './useProviderStatus' export * from './useModalManager' diff --git a/libs/client/shared/src/hooks/useTeller.ts b/libs/client/shared/src/hooks/useTeller.ts new file mode 100644 index 00000000..b20c056b --- /dev/null +++ b/libs/client/shared/src/hooks/useTeller.ts @@ -0,0 +1,180 @@ +import { useEffect, useState } from 'react' +import * as Sentry from '@sentry/react' +import type { Logger } from '../providers/LogProvider' +import toast from 'react-hot-toast' +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 [loading, error] = useScript({ + src: TC_JS, + checkForExisting: true, + }) + + const [teller, setTeller] = useState(null) + const [iframeLoaded, setIframeLoaded] = useState(false) + + const createTellerInstance = (institutionId: string) => { + return createTeller( + { + ...options, + onSuccess: async (enrollment: TellerConnectEnrollment) => { + logger.debug(`User enrolled successfully`, enrollment) + 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() + setTeller(tellerInstance) + }, + } +} + +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, + } +} diff --git a/libs/client/shared/src/utils/index.ts b/libs/client/shared/src/utils/index.ts index 90694d3a..a1ac6e57 100644 --- a/libs/client/shared/src/utils/index.ts +++ b/libs/client/shared/src/utils/index.ts @@ -2,4 +2,3 @@ export * from './image-loaders' export * from './browser-utils' export * from './account-utils' export * from './form-utils' -export * from './teller-utils' diff --git a/libs/client/shared/src/utils/teller-utils.ts b/libs/client/shared/src/utils/teller-utils.ts deleted file mode 100644 index 4265ae84..00000000 --- a/libs/client/shared/src/utils/teller-utils.ts +++ /dev/null @@ -1,39 +0,0 @@ -import * as Sentry from '@sentry/react' -import type { Logger } from '../providers/LogProvider' -import type { - TellerConnectEnrollment, - TellerConnectFailure, - TellerConnectOptions, -} from 'teller-connect-react' - -type TellerEnvironment = 'sandbox' | 'development' | 'production' | undefined -type TellerAccountSelection = 'disabled' | 'single' | 'multiple' | undefined - -export const getTellerConfig = (logger: Logger, institutionId: string | undefined) => { - 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, - ...(institutionId !== undefined ? { institution: institutionId } : {}), - onInit: () => { - logger.debug(`Teller Connect has initialized`) - }, - onSuccess: (enrollment: TellerConnectEnrollment) => { - logger.debug(`User enrolled successfully`, enrollment) - }, - 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 -} diff --git a/libs/server/features/src/providers/teller/teller.service.ts b/libs/server/features/src/providers/teller/teller.service.ts index a5847041..931e6721 100644 --- a/libs/server/features/src/providers/teller/teller.service.ts +++ b/libs/server/features/src/providers/teller/teller.service.ts @@ -6,10 +6,11 @@ import type { IAccountConnectionProvider, } from '../../account-connection' import { SharedUtil } from '@maybe-finance/shared' +import type { SharedType } from '@maybe-finance/shared' import type { SyncConnectionOptions, CryptoService, IETL } from '@maybe-finance/server/shared' import _ from 'lodash' import { ErrorUtil, etl } from '@maybe-finance/server/shared' -import type { TellerApi } from '@maybe-finance/teller-api' +import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api' export interface ITellerConnect { generateConnectUrl(userId: User['id'], institutionId: string): Promise<{ link: string }> @@ -67,13 +68,22 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr async delete(connection: AccountConnection) { // purge teller data - if (connection.tellerAccessToken && connection.tellerAccountId) { - await this.teller.deleteAccount({ - accessToken: this.crypto.decrypt(connection.tellerAccessToken), - accountId: connection.tellerAccountId, + if (connection.tellerAccessToken && connection.tellerEnrollmentId) { + const accounts = await this.prisma.account.findMany({ + where: { accountConnectionId: connection.id }, }) - this.logger.info(`Item ${connection.tellerAccountId} removed`) + for (const account of accounts) { + if (!account.tellerAccountId) continue + await this.teller.deleteAccount({ + accessToken: this.crypto.decrypt(connection.tellerAccessToken), + accountId: account.tellerAccountId, + }) + + this.logger.info(`Teller account ${account.id} removed`) + } + + this.logger.info(`Teller enrollment ${connection.tellerEnrollmentId} removed`) } } @@ -124,4 +134,51 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr } }) } + + async handleEnrollment( + userId: User['id'], + institution: Pick, + 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') + } + + // Create account connection on exchange; accounts + txns will sync later with webhook + const [accountConnection] = await this.prisma.$transaction([ + 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', + }, + }), + ]) + + await this.prisma.user.update({ + where: { id: userId }, + data: { + tellerUserId: enrollment.user.id, + }, + }) + + return accountConnection + } } diff --git a/libs/teller-api/src/teller-api.ts b/libs/teller-api/src/teller-api.ts index 16b982f7..b6fe48d0 100644 --- a/libs/teller-api/src/teller-api.ts +++ b/libs/teller-api/src/teller-api.ts @@ -137,12 +137,12 @@ export class TellerApi { } private async getApi(accessToken: string): Promise { - const cert = fs.readFileSync('./certs/certificate.pem', 'utf8') - const key = fs.readFileSync('./certs/private_key.pem', 'utf8') + const cert = fs.readFileSync('./certs/certificate.pem') + const key = fs.readFileSync('./certs/private_key.pem') const agent = new https.Agent({ - cert, - key, + cert: cert, + key: key, }) if (!this.api) { @@ -153,15 +153,10 @@ export class TellerApi { headers: { Accept: 'application/json', }, - }) - - this.api.interceptors.request.use((config) => { - // Add the access_token to the auth object - config.auth = { - username: 'ACCESS_TOKEN', - password: accessToken, - } - return config + auth: { + username: accessToken, + password: '', + }, }) } diff --git a/libs/teller-api/src/types/enrollment.ts b/libs/teller-api/src/types/enrollment.ts new file mode 100644 index 00000000..e85b2552 --- /dev/null +++ b/libs/teller-api/src/types/enrollment.ts @@ -0,0 +1,13 @@ +export type Enrollment = { + accessToken: string + user: { + id: string + } + enrollment: { + id: string + institution: { + name: string + } + } + signatures?: string[] +} diff --git a/libs/teller-api/src/types/index.ts b/libs/teller-api/src/types/index.ts index ca90d347..863d6f9e 100644 --- a/libs/teller-api/src/types/index.ts +++ b/libs/teller-api/src/types/index.ts @@ -3,6 +3,7 @@ export * from './account-balance' export * from './account-details' export * from './authentication' export * from './error' +export * from './enrollment' export * from './identity' export * from './institutions' export * from './transactions' diff --git a/package.json b/package.json index 6b62db4a..0ddb5222 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "react-popper": "^2.3.0", "react-ranger": "^2.1.0", "react-responsive": "^9.0.0-beta.10", + "react-script-hook": "^1.7.2", "regenerator-runtime": "0.13.7", "sanitize-html": "^2.8.1", "smooth-scroll-into-view-if-needed": "^1.1.33", diff --git a/prisma/migrations/20240116224800_add_enrollment_id_for_teller/migration.sql b/prisma/migrations/20240116224800_add_enrollment_id_for_teller/migration.sql new file mode 100644 index 00000000..e9dddb32 --- /dev/null +++ b/prisma/migrations/20240116224800_add_enrollment_id_for_teller/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 918f1349..19299720 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,8 +71,8 @@ model AccountConnection { finicityError Json? @map("finicity_error") // teller data - tellerAccountId String? @map("teller_account_id") tellerAccessToken String? @map("teller_access_token") + tellerEnrollmentId String? @map("teller_enrollment_id") tellerInstitutionId String? @map("teller_institution_id") tellerError Json? @map("teller_error") From 726180954b9812930dd4e64dec1ede2a2e00a4ae Mon Sep 17 00:00:00 2001 From: David Neuman Date: Tue, 16 Jan 2024 20:16:22 -0500 Subject: [PATCH 09/35] Add default value for ngrok domain field --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index e0656e03..66ff2116 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,7 +31,7 @@ services: ports: - 4551:4551 environment: - - DOMAIN=${NGROK_DOMAIN} + - DOMAIN=${NGROK_DOMAIN:-host.docker.internal} - PORT=3333 - AUTH_TOKEN=${NGROK_AUTH_TOKEN} - DEBUG=true From 2575ccf311be0744fd17ea211137455381754c86 Mon Sep 17 00:00:00 2001 From: Tyler Myracle Date: Tue, 16 Jan 2024 22:03:50 -0600 Subject: [PATCH 10/35] add fix etl and test Teller sandbox --- .env.example | 2 +- apps/workers/src/main.ts | 2 +- libs/client/shared/src/api/useTellerApi.ts | 5 +++ libs/client/shared/src/hooks/useTeller.ts | 2 +- .../src/providers/teller/teller.etl.ts | 13 +++---- .../src/providers/teller/teller.service.ts | 38 +++++++++++-------- libs/server/shared/src/utils/teller-utils.ts | 25 ++++-------- libs/teller-api/src/types/accounts.ts | 2 +- 8 files changed, 45 insertions(+), 44 deletions(-) diff --git a/.env.example b/.env.example index abd0167b..56a62b67 100644 --- a/.env.example +++ b/.env.example @@ -57,4 +57,4 @@ NX_POSTMARK_API_TOKEN= ######################################################################## NX_PLAID_SECRET= NX_FINICITY_APP_KEY= -NX_FINICITY_PARTNER_SECRET= \ No newline at end of file +NX_FINICITY_PARTNER_SECRET= diff --git a/apps/workers/src/main.ts b/apps/workers/src/main.ts index 7db31bb0..a0cf5e82 100644 --- a/apps/workers/src/main.ts +++ b/apps/workers/src/main.ts @@ -140,7 +140,7 @@ syncInstitutionQueue.add( 'sync-teller-institutions', {}, { - repeat: { cron: '* */24 * * *' }, // Run every 24 hours + repeat: { cron: '0 0 */1 * *' }, // Run every 24 hours } ) diff --git a/libs/client/shared/src/api/useTellerApi.ts b/libs/client/shared/src/api/useTellerApi.ts index 2a323da7..a59287a1 100644 --- a/libs/client/shared/src/api/useTellerApi.ts +++ b/libs/client/shared/src/api/useTellerApi.ts @@ -6,6 +6,7 @@ import type { SharedType } from '@maybe-finance/shared' import { invalidateAccountQueries } from '../utils' import type { AxiosInstance } from 'axios' import type { TellerTypes } from '@maybe-finance/teller-api' +import { useAccountConnectionApi } from './useAccountConnectionApi' type TellerInstitution = { name: string @@ -30,6 +31,9 @@ export function useTellerApi() { const { axios } = useAxiosWithAuth() const api = useMemo(() => TellerApi(axios), [axios]) + const { useSyncConnection } = useAccountConnectionApi() + const syncConnection = useSyncConnection() + const addConnectionToState = (connection: SharedType.AccountConnection) => { const accountsData = queryClient.getQueryData(['accounts']) if (!accountsData) @@ -50,6 +54,7 @@ export function useTellerApi() { useMutation(api.handleEnrollment, { onSuccess: (_connection) => { addConnectionToState(_connection) + syncConnection.mutate(_connection.id) toast.success(`Account connection added!`) }, onSettled: () => { diff --git a/libs/client/shared/src/hooks/useTeller.ts b/libs/client/shared/src/hooks/useTeller.ts index b20c056b..6e09567f 100644 --- a/libs/client/shared/src/hooks/useTeller.ts +++ b/libs/client/shared/src/hooks/useTeller.ts @@ -58,7 +58,7 @@ export const useTellerConnect = (options: TellerConnectOptions, logger: Logger) { ...options, onSuccess: async (enrollment: TellerConnectEnrollment) => { - logger.debug(`User enrolled successfully`, enrollment) + logger.debug('User enrolled successfully') try { await handleEnrollment.mutateAsync({ institution: { diff --git a/libs/server/features/src/providers/teller/teller.etl.ts b/libs/server/features/src/providers/teller/teller.etl.ts index 241934bb..524659c9 100644 --- a/libs/server/features/src/providers/teller/teller.etl.ts +++ b/libs/server/features/src/providers/teller/teller.etl.ts @@ -1,6 +1,6 @@ import type { AccountConnection, PrismaClient } from '@prisma/client' import type { Logger } from 'winston' -import { SharedUtil, AccountUtil, type SharedType } from '@maybe-finance/shared' +import { SharedUtil, type SharedType } from '@maybe-finance/shared' import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api' import { DbUtil, TellerUtil, type IETL, type ICryptoService } from '@maybe-finance/server/shared' import { Prisma } from '@prisma/client' @@ -117,8 +117,6 @@ export class TellerETL implements IETL { return [ // upsert accounts ...accounts.map((tellerAccount) => { - const type = TellerUtil.getType(tellerAccount.type) - const classification = AccountUtil.getClassification(type) return this.prisma.account.upsert({ where: { accountConnectionId_tellerAccountId: { @@ -132,12 +130,13 @@ export class TellerETL implements IETL { categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type), subcategoryProvider: tellerAccount.subtype ?? 'other', accountConnectionId: connection.id, + userId: connection.userId, tellerAccountId: tellerAccount.id, name: tellerAccount.name, tellerType: tellerAccount.type, tellerSubtype: tellerAccount.subtype, mask: tellerAccount.last_four, - ...TellerUtil.getAccountBalanceData(tellerAccount, classification), + ...TellerUtil.getAccountBalanceData(tellerAccount), }, update: { type: TellerUtil.getType(tellerAccount.type), @@ -145,7 +144,7 @@ export class TellerETL implements IETL { subcategoryProvider: tellerAccount.subtype ?? 'other', tellerType: tellerAccount.type, tellerSubtype: tellerAccount.subtype, - ..._.omit(TellerUtil.getAccountBalanceData(tellerAccount, classification), [ + ..._.omit(TellerUtil.getAccountBalanceData(tellerAccount), [ 'currentBalanceStrategy', 'availableBalanceStrategy', ]), @@ -226,13 +225,13 @@ export class TellerETL implements IETL { } AND teller_account_id = ${account_id.toString()}), ${id}, ${date}::date, - ${[description].filter(Boolean).join(' ')}, + ${description}, ${DbUtil.toDecimal(-amount)}, ${status === 'pending'}, ${'USD'}, ${details.counterparty.name ?? ''}, ${type}, - ${details.category ?? ''}, + ${details.category ?? ''} )` }) )} diff --git a/libs/server/features/src/providers/teller/teller.service.ts b/libs/server/features/src/providers/teller/teller.service.ts index 931e6721..7ce65280 100644 --- a/libs/server/features/src/providers/teller/teller.service.ts +++ b/libs/server/features/src/providers/teller/teller.service.ts @@ -45,6 +45,7 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr where: { id: connection.id }, data: { status: 'OK', + syncStatus: 'IDLE', }, }) break @@ -157,21 +158,6 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr throw new Error('USD_ONLY') } - // Create account connection on exchange; accounts + txns will sync later with webhook - const [accountConnection] = await this.prisma.$transaction([ - 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', - }, - }), - ]) - await this.prisma.user.update({ where: { id: userId }, data: { @@ -179,6 +165,28 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr }, }) + 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', + }, + }) + + await this.sync(accountConnection, { type: 'teller', initialSync: true }) + + await this.prisma.accountConnection.update({ + where: { id: accountConnection.id }, + data: { + status: 'OK', + syncStatus: 'IDLE', + }, + }) + return accountConnection } } diff --git a/libs/server/shared/src/utils/teller-utils.ts b/libs/server/shared/src/utils/teller-utils.ts index a8e8e3ee..0b741583 100644 --- a/libs/server/shared/src/utils/teller-utils.ts +++ b/libs/server/shared/src/utils/teller-utils.ts @@ -1,10 +1,4 @@ -import { - Prisma, - AccountCategory, - AccountType, - type AccountClassification, - type Account, -} from '@prisma/client' +import { Prisma, AccountCategory, AccountType, type Account } from '@prisma/client' import type { TellerTypes } from '@maybe-finance/teller-api' import { Duration } from 'luxon' @@ -13,10 +7,10 @@ import { Duration } from 'luxon' */ export const TELLER_WINDOW_MAX = Duration.fromObject({ years: 1 }) -export function getAccountBalanceData( - { balances, currency }: Pick, - classification: AccountClassification -): Pick< +export function getAccountBalanceData({ + balance, + currency, +}: Pick): Pick< Account, | 'currentBalanceProvider' | 'currentBalanceStrategy' @@ -24,16 +18,11 @@ export function getAccountBalanceData( | 'availableBalanceStrategy' | 'currencyCode' > { - // Flip balance values to positive for liabilities - const sign = classification === 'liability' ? -1 : 1 - return { - currentBalanceProvider: new Prisma.Decimal( - balances.ledger ? sign * Number(balances.ledger) : 0 - ), + currentBalanceProvider: new Prisma.Decimal(balance.ledger ? Number(balance.ledger) : 0), currentBalanceStrategy: 'current', availableBalanceProvider: new Prisma.Decimal( - balances.available ? sign * Number(balances.available) : 0 + balance.available ? Number(balance.available) : 0 ), availableBalanceStrategy: 'available', currencyCode: currency, diff --git a/libs/teller-api/src/types/accounts.ts b/libs/teller-api/src/types/accounts.ts index 5df29953..d1ccbcc1 100644 --- a/libs/teller-api/src/types/accounts.ts +++ b/libs/teller-api/src/types/accounts.ts @@ -50,7 +50,7 @@ interface CreditAccount extends BaseAccount { export type Account = DepositoryAccount | CreditAccount export type AccountWithBalances = Account & { - balances: AccountBalance + balance: AccountBalance } export type GetAccountsResponse = Account[] From 7d62c6e68819b3ae60b164b116d6f1e8e36424f0 Mon Sep 17 00:00:00 2001 From: Tyler Myracle Date: Tue, 16 Jan 2024 22:20:56 -0600 Subject: [PATCH 11/35] clean up --- .env.example | 1 - 1 file changed, 1 deletion(-) diff --git a/.env.example b/.env.example index 56a62b67..58b320fe 100644 --- a/.env.example +++ b/.env.example @@ -57,4 +57,3 @@ NX_POSTMARK_API_TOKEN= ######################################################################## NX_PLAID_SECRET= NX_FINICITY_APP_KEY= -NX_FINICITY_PARTNER_SECRET= From f38c7be1a6cbd65c2ca98e09299510deb5783f58 Mon Sep 17 00:00:00 2001 From: Tyler Myracle Date: Tue, 16 Jan 2024 22:22:23 -0600 Subject: [PATCH 12/35] add new teller envs to example --- .env.example | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 58b320fe..73888889 100644 --- a/.env.example +++ b/.env.example @@ -35,8 +35,9 @@ NX_POLYGON_API_KEY= # We use Teller.io for automated banking data. You can sign up for a free # account and get a free API key at https://teller.io NX_TELLER_SIGNING_SECRET= -NX_TELLER_APP_ID= NX_TELLER_ENV=sandbox +NEXT_PUBLIC_TELLER_ENV=sandbox +NEXT_PUBLIC_TELLER_APP_ID= ######################################################################## # EMAIL From faabe6a3d44268f76b2d3f15fcc5ab5ac307c274 Mon Sep 17 00:00:00 2001 From: Tyler Myracle Date: Tue, 16 Jan 2024 22:23:17 -0600 Subject: [PATCH 13/35] add back --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index 73888889..12baaedb 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,4 @@ NX_POSTMARK_API_TOKEN= ######################################################################## NX_PLAID_SECRET= NX_FINICITY_APP_KEY= +NX_FINICITY_PARTNER_SECRET= From 836a0e157c3177422278589fb66ba5d5a35c98e2 Mon Sep 17 00:00:00 2001 From: Tyler Myracle Date: Tue, 16 Jan 2024 22:35:00 -0600 Subject: [PATCH 14/35] remove unused --- .../features/src/providers/teller/teller.service.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/libs/server/features/src/providers/teller/teller.service.ts b/libs/server/features/src/providers/teller/teller.service.ts index 7ce65280..defa153d 100644 --- a/libs/server/features/src/providers/teller/teller.service.ts +++ b/libs/server/features/src/providers/teller/teller.service.ts @@ -12,15 +12,6 @@ import _ from 'lodash' import { ErrorUtil, etl } from '@maybe-finance/server/shared' 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 { constructor( private readonly logger: Logger, From 7aa9bc4e3d50f53df8819d68105e0cf5da598546 Mon Sep 17 00:00:00 2001 From: Karan Handa Date: Wed, 17 Jan 2024 21:32:38 +0530 Subject: [PATCH 15/35] fix repeated data-testid --- libs/design-system/src/lib/DatePicker/DatePickerCalendar.tsx | 2 +- .../lib/DatePicker/DatePickerRange/DatePickerRangeCalendar.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/design-system/src/lib/DatePicker/DatePickerCalendar.tsx b/libs/design-system/src/lib/DatePicker/DatePickerCalendar.tsx index 34f307bc..c9cd8257 100644 --- a/libs/design-system/src/lib/DatePicker/DatePickerCalendar.tsx +++ b/libs/design-system/src/lib/DatePicker/DatePickerCalendar.tsx @@ -73,7 +73,7 @@ export function DatePickerCalendar({
- - - + {process.env.STRIPE_API_KEY && ( + + + + )}
diff --git a/libs/client/features/src/layout/DesktopLayout.tsx b/libs/client/features/src/layout/DesktopLayout.tsx index 21023d6b..55eaf49a 100644 --- a/libs/client/features/src/layout/DesktopLayout.tsx +++ b/libs/client/features/src/layout/DesktopLayout.tsx @@ -313,7 +313,6 @@ function DefaultContent({ email, }: PropsWithChildren<{ onboarding?: ReactNode; name?: string; email?: string }>) { const { addAccount } = useAccountContext() - return ( <>
@@ -338,7 +337,7 @@ function DefaultContent({ {onboarding && onboarding} - + {process.env.STRIPE_API_KEY && }
diff --git a/libs/client/features/src/layout/MobileLayout.tsx b/libs/client/features/src/layout/MobileLayout.tsx index 585af1dd..960b929e 100644 --- a/libs/client/features/src/layout/MobileLayout.tsx +++ b/libs/client/features/src/layout/MobileLayout.tsx @@ -174,7 +174,7 @@ export function MobileLayout({ children, sidebar }: MobileLayoutProps) {
- + {process.env.STRIPE_API_KEY && }
diff --git a/libs/client/features/src/user-billing/BillingPreferences.tsx b/libs/client/features/src/user-billing/BillingPreferences.tsx index 022a03d8..66064d1f 100644 --- a/libs/client/features/src/user-billing/BillingPreferences.tsx +++ b/libs/client/features/src/user-billing/BillingPreferences.tsx @@ -78,7 +78,9 @@ export function BillingPreferences() {
)} - setTakeoverOpen(false)} /> + {process.env.STRIPE_API_KEY && ( + setTakeoverOpen(false)} /> + )} ) } From 83f50a59dc39cee1802b43bf807639c173a23d64 Mon Sep 17 00:00:00 2001 From: Simon Bukin <8992420+simonbukin@users.noreply.github.com> Date: Thu, 18 Jan 2024 16:22:00 -0800 Subject: [PATCH 34/35] Remove scheduled trail-reminder email when STRIPE_API_KEY is set --- apps/workers/src/env.ts | 2 ++ apps/workers/src/main.ts | 12 +++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/workers/src/env.ts b/apps/workers/src/env.ts index ba2464b9..3bfcfb45 100644 --- a/apps/workers/src/env.ts +++ b/apps/workers/src/env.ts @@ -33,6 +33,8 @@ const envSchema = z.object({ NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'), NX_CDN_PUBLIC_BUCKET: z.string().default('REPLACE_THIS'), + + STRIPE_API_KEY: z.string().optional(), }) const env = envSchema.parse(process.env) diff --git a/apps/workers/src/main.ts b/apps/workers/src/main.ts index f6795dc6..70cdffd6 100644 --- a/apps/workers/src/main.ts +++ b/apps/workers/src/main.ts @@ -154,11 +154,13 @@ syncInstitutionQueue.add( */ sendEmailQueue.process('send-email', async (job) => await emailProcessor.send(job.data)) -sendEmailQueue.add( - 'send-email', - { type: 'trial-reminders' }, - { repeat: { cron: '0 */12 * * *' } } // Run every 12 hours -) +if (env.STRIPE_API_KEY) { + sendEmailQueue.add( + 'send-email', + { type: 'trial-reminders' }, + { repeat: { cron: '0 */12 * * *' } } // Run every 12 hours + ) +} // Fallback - usually triggered by errors not handled (or thrown) within the Bull event handlers (see above) process.on( From 5375affd8ebaee3a8f6d4d0837ea354de25e120c Mon Sep 17 00:00:00 2001 From: Tyler Myracle Date: Fri, 19 Jan 2024 01:57:53 -0600 Subject: [PATCH 35/35] update e2e tests to work with new auth --- apps/client/pages/login.tsx | 2 + apps/client/pages/register.tsx | 4 + apps/e2e/cypress.config.ts | 12 +-- apps/e2e/src/e2e/accounts.cy.ts | 82 ++---------------- apps/e2e/src/e2e/auth.cy.ts | 9 ++ apps/e2e/src/support/commands.ts | 104 ++++------------------- apps/e2e/src/support/e2e.ts | 5 +- apps/e2e/src/support/index.ts | 2 + apps/server/src/app/routes/e2e.router.ts | 18 ++-- prisma/seed.ts | 17 ++++ 10 files changed, 71 insertions(+), 184 deletions(-) create mode 100644 apps/e2e/src/e2e/auth.cy.ts create mode 100644 apps/e2e/src/support/index.ts diff --git a/apps/client/pages/login.tsx b/apps/client/pages/login.tsx index 84634495..aabcc715 100644 --- a/apps/client/pages/login.tsx +++ b/apps/client/pages/login.tsx @@ -57,6 +57,7 @@ export default function LoginPage() {
setEmail(e.currentTarget.value)} @@ -64,6 +65,7 @@ export default function LoginPage() { setFirstName(e.currentTarget.value)} /> setLastName(e.currentTarget.value)} /> setEmail(e.currentTarget.value)} @@ -82,6 +85,7 @@ export default function RegisterPage() { { - it('should sync and edit a plaid connection', () => { - cy.apiRequest({ - method: 'POST', - url: 'e2e/plaid/connect', - }).then((response) => { - expect(response.status).to.eql(200) - - // The only time we need to manually send a Plaid webhook is in Github actions when testing a PR - if (Cypress.env('WEBHOOK_TYPE') === 'mock') { - const { plaidItemId } = response.body.data.json - - cy.request({ - method: 'POST', - url: `${Cypress.env('API_URL')}/plaid/webhook`, - body: { - webhook_type: 'TRANSACTIONS', - webhook_code: 'HISTORICAL_UPDATE', - item_id: plaidItemId, - }, - }) - .its('status') - .should('equal', 200) - } - }) - - // Check account sidebar names and balances - cy.visit('/accounts') - cy.getByTestId('account-group', { timeout: 20_000 }) - - assertSidebarAccounts([ - ['Assets', '$20,000'], - ['Cash', '$20,000'], - ['Sandbox Savings', '$15,000'], - ['Sandbox Checking', '$5,000'], - ['Debts', '$950'], - ['Credit Cards', '$950'], - ['Sandbox CC', '$950'], - ]) - - // Check current net worth - cy.visit('/') - cy.getByTestId('current-data-value').should('contain.text', '$19,050.00') - - // Visit each account page, edit details, re-validate amounts - cy.contains('a', 'Sandbox Checking').click() - cy.getByTestId('current-data-value').should('contain.text', '$5,000.00') - - cy.contains('a', 'Sandbox Savings').click() - cy.getByTestId('current-data-value').should('contain.text', '$15,000.00') - - cy.contains('a', 'Sandbox CC').click() - cy.getByTestId('current-data-value').should('contain.text', '$950.00') - - openEditAccountModal() - - cy.getByTestId('connected-account-form').within(() => { - cy.get('input[name="name"]') - .should('have.value', 'Sandbox CC') - .clear() - .type('Credit Credit') - cy.get('input[name="categoryUser"]').should('have.value', 'credit') - cy.get('input[name="startDate"]') - .should('have.value', '') - .type(DateTime.now().minus({ months: 1 }).toFormat('MMddyyyy')) - cy.root().submit() - }) - - // Should be able to submit empty start date on connected account - openEditAccountModal() - cy.getByTestId('connected-account-form').within(() => { - cy.get('input[name="startDate"]').clear() - cy.root().submit() - }) - }) - it('should interpolate and display manual vehicle account data', () => { cy.getByTestId('add-account-button').click() cy.contains('h4', 'Add account') @@ -149,7 +74,8 @@ describe('Accounts', () => { cy.contains('h4', 'Add real estate') // Details - cy.get('input[name="country"]').select('GB') + cy.contains('label', 'Country').click() + cy.contains('button', 'Uganda').click() cy.get('input[name="line1"]').focus().type('123 Example St') cy.get('input[name="city"]').type('New York') cy.get('input[name="state"]').type('NY') @@ -188,7 +114,9 @@ describe('Accounts', () => { openEditAccountModal() cy.getByTestId('property-form').within(() => { - cy.get('input[name="country]').should('have.value', 'GB').clear().select('FR') + cy.get('input[name="country"]').should('have.value', 'UG') + cy.contains('label', 'Country').click() + cy.contains('button', 'United States').click() cy.get('input[name="line1"]') .should('have.value', '123 Example St') .clear() diff --git a/apps/e2e/src/e2e/auth.cy.ts b/apps/e2e/src/e2e/auth.cy.ts new file mode 100644 index 00000000..8150a9ea --- /dev/null +++ b/apps/e2e/src/e2e/auth.cy.ts @@ -0,0 +1,9 @@ +describe('Auth', () => { + beforeEach(() => cy.visit('/')) + + describe('Logging in', () => { + it('should show the home page of an authenticated user', () => { + cy.contains('h5', 'Assets & Debts') + }) + }) +}) diff --git a/apps/e2e/src/support/commands.ts b/apps/e2e/src/support/commands.ts index 9084edc3..676fb30b 100644 --- a/apps/e2e/src/support/commands.ts +++ b/apps/e2e/src/support/commands.ts @@ -1,17 +1,13 @@ -import jwtDecode from 'jwt-decode' - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Cypress { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface Chainable { - login(username: string, password: string): Chainable - apiRequest(...params: Parameters): Chainable - getByTestId(...parameters: Parameters): Chainable - selectDate(date: Date): Chainable - preserveAccessToken(): Chainable - restoreAccessToken(): Chainable - } +// eslint-disable-next-line @typescript-eslint/no-namespace +declare namespace Cypress { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface Chainable { + login(): Chainable + apiRequest(...params: Parameters): Chainable + getByTestId(...parameters: Parameters): Chainable + selectDate(date: Date): Chainable + preserveAccessToken(): Chainable + restoreAccessToken(): Chainable } } @@ -20,13 +16,10 @@ Cypress.Commands.add('getByTestId', (testId, ...rest) => { }) Cypress.Commands.add('apiRequest', ({ url, headers = {}, ...options }, ...rest) => { - const accessToken = window.localStorage.getItem('token') - return cy.request( { url: `${Cypress.env('API_URL')}/${url}`, headers: { - Authorization: `Bearer ${accessToken}`, ...headers, }, ...options, @@ -35,74 +28,11 @@ Cypress.Commands.add('apiRequest', ({ url, headers = {}, ...options }, ...rest) ) }) -/** - * Logs in with the engineering CI account - */ -Cypress.Commands.add('login', (username, password) => { - // Preserves login across tests - cy.session('login-session-key', login, { - validate() { - cy.apiRequest({ url: 'e2e' }).its('status').should('eq', 200) - }, - }) - - function login() { - const client_id = Cypress.env('AUTH0_CLIENT_ID') - const audience = 'https://maybe-finance-api/v1' - const scope = 'openid profile email offline_access' - const accessTokenStorageKey = `@@auth0spajs@@::${client_id}::${audience}::${scope}` - const AUTH_DOMAIN = Cypress.env('AUTH0_DOMAIN') - - cy.log(`Logging in as ${username}`) - - /** - * Uses the official Cypress Auth0 strategy for testing with Auth0 - * https://docs.cypress.io/guides/testing-strategies/auth0-authentication#Auth0-Application-Setup - * - * Relevant Auth0 endpoint - * https://auth0.com/docs/api/authentication?javascript#resource-owner-password - */ - cy.request({ - method: 'POST', - url: `https://${AUTH_DOMAIN}/oauth/token`, - body: { - grant_type: 'password', - username, - password, - audience, - scope, - client_id, - }, - }).then(({ body }) => { - const claims = jwtDecode(body.id_token) - - const { nickname, name, picture, updated_at, email, email_verified, sub, exp } = claims - - const item = { - body: { - ...body, - decodedToken: { - claims, - user: { - nickname, - name, - picture, - updated_at, - email, - email_verified, - sub, - }, - audience, - client_id, - }, - }, - expiresAt: exp, - } - - window.localStorage.setItem(accessTokenStorageKey, JSON.stringify(item)) - window.localStorage.setItem('token', body.access_token) - - cy.visit('/') - }) - } +Cypress.Commands.add('login', () => { + cy.visit('/login') + cy.get('input[name="email"]').type('bond@007.com') + cy.get('input[name="password"]').type('TestPassword123') + cy.get('button[type="submit"]').click() + //eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000) }) diff --git a/apps/e2e/src/support/e2e.ts b/apps/e2e/src/support/e2e.ts index a8e64fd1..be528804 100644 --- a/apps/e2e/src/support/e2e.ts +++ b/apps/e2e/src/support/e2e.ts @@ -2,8 +2,7 @@ import './commands' beforeEach(() => { // Login - // Rate limit 30 / min - https://auth0.com/docs/troubleshoot/customer-support/operational-policies/rate-limit-policy#limits-for-non-production-tenants-of-paying-customers-and-all-tenants-of-free-customers - cy.login(Cypress.env('AUTH0_EMAIL'), Cypress.env('AUTH0_PASSWORD')) + cy.login() // Delete the current user to wipe all data before test cy.apiRequest({ @@ -14,6 +13,6 @@ beforeEach(() => { expect(response.status).to.equal(200) }) - // Re-login (JWT should still be valid) + // Go back to dashboard cy.visit('/') }) diff --git a/apps/e2e/src/support/index.ts b/apps/e2e/src/support/index.ts new file mode 100644 index 00000000..5e5628a4 --- /dev/null +++ b/apps/e2e/src/support/index.ts @@ -0,0 +1,2 @@ +import './commands' +import './e2e' diff --git a/apps/server/src/app/routes/e2e.router.ts b/apps/server/src/app/routes/e2e.router.ts index 0b4bc830..5b9edb29 100644 --- a/apps/server/src/app/routes/e2e.router.ts +++ b/apps/server/src/app/routes/e2e.router.ts @@ -6,13 +6,13 @@ import endpoint from '../lib/endpoint' const router = Router() -router.use((req, res, next) => { - const roles = req.user?.['https://maybe.co/roles'] +const testUserId = 'test_ec3ee8a4-fa01-4f11-8ac5-9c49dd7fbae4' - if (roles?.includes('CIUser') || roles?.includes('Admin')) { +router.use((req, res, next) => { + if (req.user?.sub === testUserId) { next() } else { - res.status(401).send('Route only available to CIUser and Admin roles') + res.status(401).send('Route only available to test users') } }) @@ -47,14 +47,14 @@ router.post( trialLapsed: z.boolean().default(false), }), resolve: async ({ ctx, input }) => { - ctx.logger.debug(`Resetting CI user ${ctx.user!.authId}`) - await ctx.prisma.$transaction([ - ctx.prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${ctx.user!.authId};`, + ctx.prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${testUserId};`, ctx.prisma.user.create({ data: { - authId: ctx.user!.authId, - email: 'REPLACE_THIS', + authId: testUserId, + email: 'bond@007.com', + firstName: 'James', + lastName: 'Bond', dob: new Date('1990-01-01'), linkAccountDismissedAt: new Date(), // ensures our auto-account link doesn't trigger diff --git a/prisma/seed.ts b/prisma/seed.ts index da910b02..7448631e 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,4 +1,5 @@ import { Institution, PrismaClient, Provider } from '@prisma/client' +import bcrypt from 'bcrypt' const prisma = new PrismaClient() @@ -27,7 +28,23 @@ async function main() { }, ] + const hashedPassword = await bcrypt.hash('TestPassword123', 10) + await prisma.$transaction([ + // create testing auth user + prisma.authUser.upsert({ + where: { + id: 'test_ec3ee8a4-fa01-4f11-8ac5-9c49dd7fbae4', + }, + create: { + id: 'test_ec3ee8a4-fa01-4f11-8ac5-9c49dd7fbae4', + firstName: 'James', + lastName: 'Bond', + email: 'bond@007.com', + password: hashedPassword, + }, + update: {}, + }), // create institution linked to provider institutions ...institutions.map(({ id, name, providers }) => prisma.institution.upsert({