mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 15:35:22 +02:00
teller front end progress
This commit is contained in:
parent
b34563c60c
commit
b61fececc7
16 changed files with 109 additions and 91 deletions
|
@ -240,6 +240,7 @@ const userService = new UserService(
|
|||
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||
PLAID: plaidService,
|
||||
FINICITY: finicityService,
|
||||
TELLER: tellerService,
|
||||
})
|
||||
|
||||
const institutionService: IInstitutionService = new InstitutionService(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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<string>('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS)
|
||||
|
||||
const [institutionId, setInstitutionId] = useState<string | undefined>(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<HTMLInputElement>(null)
|
||||
|
||||
|
@ -44,6 +55,15 @@ export default function AccountTypeSelector({
|
|||
}
|
||||
}, [])
|
||||
|
||||
const configRef = useRef<TellerConnectOptions | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (institutionId) {
|
||||
configRef.current = BrowserUtil.getTellerConfig(logger, institutionId)
|
||||
openTeller()
|
||||
}
|
||||
}, [institutionId, logger, openTeller])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 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
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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<string | null>(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,
|
||||
}
|
||||
}
|
|
@ -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<CreatePropertyFields> }
|
||||
| { view: 'add-vehicle'; defaultValues: Partial<CreateVehicleFields> }
|
||||
|
|
|
@ -2,3 +2,4 @@ export * from './image-loaders'
|
|||
export * from './browser-utils'
|
||||
export * from './account-utils'
|
||||
export * from './form-utils'
|
||||
export * from './teller-utils'
|
||||
|
|
39
libs/client/shared/src/utils/teller-utils.ts
Normal file
39
libs/client/shared/src/utils/teller-utils.ts
Normal file
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
@ -70,7 +70,7 @@ export type SyncSecurityQueue = IQueue<SyncSecurityQueueJobData, 'sync-all-secur
|
|||
export type PurgeUserQueue = IQueue<{ userId: User['id'] }, 'purge-user'>
|
||||
export type SyncInstitutionQueue = IQueue<
|
||||
{},
|
||||
'sync-finicity-institutions' | 'sync-plaid-institutions'
|
||||
'sync-finicity-institutions' | 'sync-plaid-institutions' | 'sync-teller-institutions'
|
||||
>
|
||||
export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>
|
||||
|
||||
|
|
|
@ -137,8 +137,8 @@ export class TellerApi {
|
|||
}
|
||||
|
||||
private async getApi(accessToken: string): Promise<AxiosInstance> {
|
||||
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,
|
||||
|
|
|
@ -9,6 +9,4 @@ export type Institution = {
|
|||
|
||||
type Capability = 'detail' | 'balance' | 'transaction' | 'identity'
|
||||
|
||||
export type GetInstitutionsResponse = {
|
||||
institutions: Institution[]
|
||||
}
|
||||
export type GetInstitutionsResponse = Institution[]
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "Provider" ADD VALUE 'TELLER';
|
|
@ -493,6 +493,7 @@ model Institution {
|
|||
enum Provider {
|
||||
PLAID
|
||||
FINICITY
|
||||
TELLER
|
||||
}
|
||||
|
||||
model ProviderInstitution {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue