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({
|
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||||
PLAID: plaidService,
|
PLAID: plaidService,
|
||||||
FINICITY: finicityService,
|
FINICITY: finicityService,
|
||||||
|
TELLER: tellerService,
|
||||||
})
|
})
|
||||||
|
|
||||||
const institutionService: IInstitutionService = new InstitutionService(
|
const institutionService: IInstitutionService = new InstitutionService(
|
||||||
|
|
|
@ -259,6 +259,7 @@ export const securityPricingProcessor: ISecurityPricingProcessor = new SecurityP
|
||||||
const institutionProviderFactory = new InstitutionProviderFactory({
|
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||||
PLAID: plaidService,
|
PLAID: plaidService,
|
||||||
FINICITY: finicityService,
|
FINICITY: finicityService,
|
||||||
|
TELLER: tellerService,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const institutionService: IInstitutionService = new InstitutionService(
|
export const institutionService: IInstitutionService = new InstitutionService(
|
||||||
|
|
|
@ -115,6 +115,11 @@ syncInstitutionQueue.process(
|
||||||
async () => await institutionService.sync('FINICITY')
|
async () => await institutionService.sync('FINICITY')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
syncInstitutionQueue.process(
|
||||||
|
'sync-teller-institutions',
|
||||||
|
async () => await institutionService.sync('TELLER')
|
||||||
|
)
|
||||||
|
|
||||||
syncInstitutionQueue.add(
|
syncInstitutionQueue.add(
|
||||||
'sync-plaid-institutions',
|
'sync-plaid-institutions',
|
||||||
{},
|
{},
|
||||||
|
@ -131,6 +136,14 @@ syncInstitutionQueue.add(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
syncInstitutionQueue.add(
|
||||||
|
'sync-teller-institutions',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
repeat: { cron: '* */24 * * *' }, // Run every 24 hours
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* send-email queue
|
* 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 { RiFolderLine, RiHandCoinLine, RiLockLine, RiSearchLine } from 'react-icons/ri'
|
||||||
import maxBy from 'lodash/maxBy'
|
import maxBy from 'lodash/maxBy'
|
||||||
import {
|
import {
|
||||||
|
@ -8,11 +8,13 @@ import {
|
||||||
usePlaid,
|
usePlaid,
|
||||||
useFinicity,
|
useFinicity,
|
||||||
} from '@maybe-finance/client/shared'
|
} from '@maybe-finance/client/shared'
|
||||||
|
|
||||||
import { Input } from '@maybe-finance/design-system'
|
import { Input } from '@maybe-finance/design-system'
|
||||||
import InstitutionGrid from './InstitutionGrid'
|
import InstitutionGrid from './InstitutionGrid'
|
||||||
import { AccountTypeGrid } from './AccountTypeGrid'
|
import { AccountTypeGrid } from './AccountTypeGrid'
|
||||||
import InstitutionList, { MIN_QUERY_LENGTH } from './InstitutionList'
|
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
|
const SEARCH_DEBOUNCE_MS = 300
|
||||||
|
|
||||||
|
@ -23,18 +25,27 @@ export default function AccountTypeSelector({
|
||||||
view: string
|
view: string
|
||||||
onViewChange: (view: string) => void
|
onViewChange: (view: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
const logger = useLogger()
|
||||||
const { setAccountManager } = useAccountContext()
|
const { setAccountManager } = useAccountContext()
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||||
const debouncedSearchQuery = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS)
|
const debouncedSearchQuery = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS)
|
||||||
|
|
||||||
|
const [institutionId, setInstitutionId] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
const showInstitutionList =
|
const showInstitutionList =
|
||||||
searchQuery.length >= MIN_QUERY_LENGTH &&
|
searchQuery.length >= MIN_QUERY_LENGTH &&
|
||||||
debouncedSearchQuery.length >= MIN_QUERY_LENGTH &&
|
debouncedSearchQuery.length >= MIN_QUERY_LENGTH &&
|
||||||
view !== 'manual'
|
view !== 'manual'
|
||||||
|
|
||||||
|
const config = useMemo(
|
||||||
|
() => BrowserUtil.getTellerConfig(logger, institutionId),
|
||||||
|
[logger, institutionId]
|
||||||
|
) as TellerConnectOptions
|
||||||
|
|
||||||
const { openPlaid } = usePlaid()
|
const { openPlaid } = usePlaid()
|
||||||
const { openFinicity } = useFinicity()
|
const { openFinicity } = useFinicity()
|
||||||
|
const { open: openTeller } = useTellerConnect(config)
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
|
@ -68,6 +88,8 @@ export default function AccountTypeSelector({
|
||||||
if (!providerInstitution) {
|
if (!providerInstitution) {
|
||||||
alert('No provider found for institution')
|
alert('No provider found for institution')
|
||||||
return
|
return
|
||||||
|
} else {
|
||||||
|
setInstitutionId(providerInstitution.providerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (providerInstitution.provider) {
|
switch (providerInstitution.provider) {
|
||||||
|
@ -77,6 +99,9 @@ export default function AccountTypeSelector({
|
||||||
case 'FINICITY':
|
case 'FINICITY':
|
||||||
openFinicity(providerInstitution.providerId)
|
openFinicity(providerInstitution.providerId)
|
||||||
break
|
break
|
||||||
|
case 'TELLER':
|
||||||
|
openTeller()
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -142,7 +167,11 @@ export default function AccountTypeSelector({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) return
|
if (!data) {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
setInstitutionId(data.providerId)
|
||||||
|
}
|
||||||
|
|
||||||
switch (data.provider) {
|
switch (data.provider) {
|
||||||
case 'PLAID':
|
case 'PLAID':
|
||||||
|
@ -151,6 +180,9 @@ export default function AccountTypeSelector({
|
||||||
case 'FINICITY':
|
case 'FINICITY':
|
||||||
openFinicity(data.providerId)
|
openFinicity(data.providerId)
|
||||||
break
|
break
|
||||||
|
case 'TELLER':
|
||||||
|
openTeller()
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,5 @@ export * from './useQueryParam'
|
||||||
export * from './useScreenSize'
|
export * from './useScreenSize'
|
||||||
export * from './useAccountNotifications'
|
export * from './useAccountNotifications'
|
||||||
export * from './usePlaid'
|
export * from './usePlaid'
|
||||||
export * from './useTeller'
|
|
||||||
export * from './useProviderStatus'
|
export * from './useProviderStatus'
|
||||||
export * from './useModalManager'
|
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: 'idle' }
|
||||||
| { view: 'add-plaid'; linkToken: string }
|
| { view: 'add-plaid'; linkToken: string }
|
||||||
| { view: 'add-finicity' }
|
| { view: 'add-finicity' }
|
||||||
|
| { view: 'add-teller' }
|
||||||
| { view: 'add-account' }
|
| { view: 'add-account' }
|
||||||
| { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> }
|
| { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> }
|
||||||
| { view: 'add-vehicle'; defaultValues: Partial<CreateVehicleFields> }
|
| { view: 'add-vehicle'; defaultValues: Partial<CreateVehicleFields> }
|
||||||
|
|
|
@ -2,3 +2,4 @@ export * from './image-loaders'
|
||||||
export * from './browser-utils'
|
export * from './browser-utils'
|
||||||
export * from './account-utils'
|
export * from './account-utils'
|
||||||
export * from './form-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
|
provider_institution pi
|
||||||
SET
|
SET
|
||||||
institution_id = i.id,
|
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
|
FROM
|
||||||
duplicates d
|
duplicates d
|
||||||
INNER JOIN institutions i ON i.name = d.name AND i.url = d.url
|
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() {
|
async getInstitutions() {
|
||||||
const tellerInstitutions = await SharedUtil.paginate({
|
const tellerInstitutions = await SharedUtil.paginate({
|
||||||
pageSize: 500,
|
pageSize: 10000,
|
||||||
delay:
|
delay:
|
||||||
process.env.NODE_ENV !== 'production'
|
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
|
milliseconds: 7_000, // Sandbox rate limited at 10 calls / minute
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
fetchData: (offset, count) =>
|
fetchData: () =>
|
||||||
SharedUtil.withRetry(
|
SharedUtil.withRetry(
|
||||||
() =>
|
() =>
|
||||||
this.teller.getInstitutions().then((data) => {
|
this.teller.getInstitutions().then((data) => {
|
||||||
this.logger.debug(
|
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,
|
maxRetries: 3,
|
||||||
onError: (error, attempt) => {
|
onError: (error, attempt) => {
|
||||||
this.logger.error(
|
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) }
|
{ error: ErrorUtil.parseError(error) }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -115,10 +115,11 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
|
||||||
return {
|
return {
|
||||||
providerId: id,
|
providerId: id,
|
||||||
name,
|
name,
|
||||||
url: undefined,
|
url: null,
|
||||||
logo: `https://teller.io/images/banks/${id}.jpg}`,
|
logo: null,
|
||||||
primaryColor: undefined,
|
logoUrl: `https://teller.io/images/banks/${id}.jpg`,
|
||||||
oauth: undefined,
|
primaryColor: null,
|
||||||
|
oauth: false,
|
||||||
data: tellerInstitution,
|
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 PurgeUserQueue = IQueue<{ userId: User['id'] }, 'purge-user'>
|
||||||
export type SyncInstitutionQueue = IQueue<
|
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'>
|
export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>
|
||||||
|
|
||||||
|
|
|
@ -137,8 +137,8 @@ export class TellerApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getApi(accessToken: string): Promise<AxiosInstance> {
|
private async getApi(accessToken: string): Promise<AxiosInstance> {
|
||||||
const cert = fs.readFileSync('../../../certs/teller-certificate.pem', 'utf8')
|
const cert = fs.readFileSync('./certs/certificate.pem', 'utf8')
|
||||||
const key = fs.readFileSync('../../../certs/teller-private-key.pem', 'utf8')
|
const key = fs.readFileSync('./certs/private_key.pem', 'utf8')
|
||||||
|
|
||||||
const agent = new https.Agent({
|
const agent = new https.Agent({
|
||||||
cert,
|
cert,
|
||||||
|
|
|
@ -9,6 +9,4 @@ export type Institution = {
|
||||||
|
|
||||||
type Capability = 'detail' | 'balance' | 'transaction' | 'identity'
|
type Capability = 'detail' | 'balance' | 'transaction' | 'identity'
|
||||||
|
|
||||||
export type GetInstitutionsResponse = {
|
export type GetInstitutionsResponse = Institution[]
|
||||||
institutions: Institution[]
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "Provider" ADD VALUE 'TELLER';
|
|
@ -493,6 +493,7 @@ model Institution {
|
||||||
enum Provider {
|
enum Provider {
|
||||||
PLAID
|
PLAID
|
||||||
FINICITY
|
FINICITY
|
||||||
|
TELLER
|
||||||
}
|
}
|
||||||
|
|
||||||
model ProviderInstitution {
|
model ProviderInstitution {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue