1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 15:35:22 +02:00

Merge pull request #98 from tmyracle/teller-p2-service

Feature: Support Teller Part 2: ETL Service
This commit is contained in:
Josh Pigford 2024-01-15 22:40:47 -06:00 committed by GitHub
commit e4043cd090
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 653 additions and 29 deletions

View file

@ -20,6 +20,7 @@ NX_FINICITY_PARTNER_SECRET=
# Teller API keys (https://teller.io) # Teller API keys (https://teller.io)
NX_TELLER_SIGNING_SECRET= NX_TELLER_SIGNING_SECRET=
NX_TELLER_APP_ID= NX_TELLER_APP_ID=
NX_TELLER_ENV=sandbox
# Email credentials # Email credentials
NX_POSTMARK_FROM_ADDRESS=account@example.com NX_POSTMARK_FROM_ADDRESS=account@example.com

View file

@ -43,6 +43,7 @@ import {
FinicityWebhookHandler, FinicityWebhookHandler,
PlaidWebhookHandler, PlaidWebhookHandler,
TellerService, TellerService,
TellerETL,
TellerWebhookHandler, TellerWebhookHandler,
InsightService, InsightService,
SecurityPricingService, SecurityPricingService,
@ -53,7 +54,6 @@ import {
ProjectionCalculator, ProjectionCalculator,
StripeWebhookHandler, StripeWebhookHandler,
} from '@maybe-finance/server/features' } from '@maybe-finance/server/features'
import { SharedType } from '@maybe-finance/shared'
import prisma from './prisma' import prisma from './prisma'
import plaid, { getPlaidWebhookUrl } from './plaid' import plaid, { getPlaidWebhookUrl } from './plaid'
import finicity, { getFinicityTxPushUrl, getFinicityWebhookUrl } from './finicity' import finicity, { getFinicityTxPushUrl, getFinicityWebhookUrl } from './finicity'
@ -149,8 +149,10 @@ const tellerService = new TellerService(
logger.child({ service: 'TellerService' }), logger.child({ service: 'TellerService' }),
prisma, prisma,
teller, teller,
new TellerETL(logger.child({ service: 'TellerETL' }), prisma, teller, cryptoService),
cryptoService,
getTellerWebhookUrl(), getTellerWebhookUrl(),
true env.NX_TELLER_ENV === 'sandbox'
) )
// account-connection // account-connection
@ -158,6 +160,7 @@ const tellerService = new TellerService(
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({ const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
plaid: plaidService, plaid: plaidService,
finicity: finicityService, finicity: finicityService,
teller: tellerService,
}) })
const transactionStrategy = new TransactionBalanceSyncStrategy( const transactionStrategy = new TransactionBalanceSyncStrategy(

View file

@ -43,6 +43,7 @@ const envSchema = z.object({
NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'), NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'),
NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'), NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'),
NX_TELLER_ENV: z.string().default('sandbox'),
NX_SENTRY_DSN: z.string().optional(), NX_SENTRY_DSN: z.string().optional(),
NX_SENTRY_ENV: z.string().optional(), NX_SENTRY_ENV: z.string().optional(),

View file

@ -28,6 +28,8 @@ import {
LoanBalanceSyncStrategy, LoanBalanceSyncStrategy,
PlaidETL, PlaidETL,
PlaidService, PlaidService,
TellerETL,
TellerService,
SecurityPricingProcessor, SecurityPricingProcessor,
SecurityPricingService, SecurityPricingService,
TransactionBalanceSyncStrategy, TransactionBalanceSyncStrategy,
@ -55,6 +57,7 @@ import logger from './logger'
import prisma from './prisma' import prisma from './prisma'
import plaid from './plaid' import plaid from './plaid'
import finicity from './finicity' import finicity from './finicity'
import teller from './teller'
import postmark from './postmark' import postmark from './postmark'
import stripe from './stripe' import stripe from './stripe'
import env from '../../env' import env from '../../env'
@ -124,11 +127,22 @@ const finicityService = new FinicityService(
env.NX_FINICITY_ENV === 'sandbox' env.NX_FINICITY_ENV === 'sandbox'
) )
const tellerService = new TellerService(
logger.child({ service: 'TellerService' }),
prisma,
teller,
new TellerETL(logger.child({ service: 'TellerETL' }), prisma, teller, cryptoService),
cryptoService,
'',
env.NX_TELLER_ENV === 'sandbox'
)
// account-connection // account-connection
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({ const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
plaid: plaidService, plaid: plaidService,
finicity: finicityService, finicity: finicityService,
teller: tellerService,
}) })
const transactionStrategy = new TransactionBalanceSyncStrategy( const transactionStrategy = new TransactionBalanceSyncStrategy(

View file

@ -0,0 +1,5 @@
import { TellerApi } from '@maybe-finance/teller-api'
const teller = new TellerApi()
export default teller

View file

@ -17,6 +17,7 @@ const envSchema = z.object({
NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'), NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'),
NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'), NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'),
NX_TELLER_ENV: z.string().default('sandbox'),
NX_SENTRY_DSN: z.string().optional(), NX_SENTRY_DSN: z.string().optional(),
NX_SENTRY_ENV: z.string().optional(), NX_SENTRY_ENV: z.string().optional(),

View file

@ -1,2 +1,3 @@
export * from './teller.webhook' export * from './teller.webhook'
export * from './teller.service' export * from './teller.service'
export * from './teller.etl'

View file

@ -0,0 +1,271 @@
import type { AccountConnection, PrismaClient } from '@prisma/client'
import type { Logger } from 'winston'
import { SharedUtil, AccountUtil, 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'
import _ from 'lodash'
import { DateTime } from 'luxon'
export type TellerRawData = {
accounts: TellerTypes.Account[]
transactions: TellerTypes.Transaction[]
transactionsDateRange: SharedType.DateRange<DateTime>
}
export type TellerData = {
accounts: TellerTypes.AccountWithBalances[]
transactions: TellerTypes.Transaction[]
transactionsDateRange: SharedType.DateRange<DateTime>
}
type Connection = Pick<
AccountConnection,
'id' | 'userId' | 'tellerInstitutionId' | 'tellerAccessToken'
>
export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
public constructor(
private readonly logger: Logger,
private readonly prisma: PrismaClient,
private readonly teller: Pick<
TellerApi,
'getAccounts' | 'getTransactions' | 'getAccountBalances'
>,
private readonly crypto: ICryptoService
) {}
async extract(connection: Connection): Promise<TellerRawData> {
if (!connection.tellerInstitutionId) {
throw new Error(`connection ${connection.id} is missing tellerInstitutionId`)
}
if (!connection.tellerAccessToken) {
throw new Error(`connection ${connection.id} is missing tellerAccessToken`)
}
const accessToken = this.crypto.decrypt(connection.tellerAccessToken)
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: connection.userId },
select: {
id: true,
tellerUserId: true,
},
})
if (!user.tellerUserId) {
throw new Error(`user ${user.id} is missing tellerUserId`)
}
// TODO: Check if Teller supports date ranges for transactions
const transactionsDateRange = {
start: DateTime.now().minus(TellerUtil.TELLER_WINDOW_MAX),
end: DateTime.now(),
}
const accounts = await this._extractAccounts(accessToken)
const transactions = await this._extractTransactions(
accessToken,
accounts.map((a) => a.id)
)
this.logger.info(
`Extracted Teller data for customer ${user.tellerUserId} accounts=${accounts.length} transactions=${transactions.length}`,
{ connection: connection.id, transactionsDateRange }
)
return {
accounts,
transactions,
transactionsDateRange,
}
}
async transform(_connection: Connection, data: TellerData): Promise<TellerData> {
return {
...data,
}
}
async load(connection: Connection, data: TellerData): Promise<void> {
await this.prisma.$transaction([
...this._loadAccounts(connection, data),
...this._loadTransactions(connection, data),
])
this.logger.info(`Loaded Teller data for connection ${connection.id}`, {
connection: connection.id,
})
}
private async _extractAccounts(accessToken: string) {
const accounts = await this.teller.getAccounts({ accessToken })
const accountsWithBalances = await Promise.all(
accounts.map(async (a) => {
const balance = await this.teller.getAccountBalances({
accountId: a.id,
accessToken,
})
return { ...a, balance }
})
)
return accountsWithBalances
}
private _loadAccounts(connection: Connection, { accounts }: Pick<TellerData, 'accounts'>) {
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: {
accountConnectionId: connection.id,
tellerAccountId: tellerAccount.id,
},
},
create: {
type: TellerUtil.getType(tellerAccount.type),
provider: 'teller',
categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type),
subcategoryProvider: tellerAccount.subtype ?? 'other',
accountConnectionId: connection.id,
tellerAccountId: tellerAccount.id,
name: tellerAccount.name,
tellerType: tellerAccount.type,
tellerSubtype: tellerAccount.subtype,
mask: tellerAccount.last_four,
...TellerUtil.getAccountBalanceData(tellerAccount, classification),
},
update: {
type: TellerUtil.getType(tellerAccount.type),
categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type),
subcategoryProvider: tellerAccount.subtype ?? 'other',
tellerType: tellerAccount.type,
tellerSubtype: tellerAccount.subtype,
..._.omit(TellerUtil.getAccountBalanceData(tellerAccount, classification), [
'currentBalanceStrategy',
'availableBalanceStrategy',
]),
},
})
}),
// any accounts that are no longer in Teller should be marked inactive
this.prisma.account.updateMany({
where: {
accountConnectionId: connection.id,
AND: [
{ tellerAccountId: { not: null } },
{ tellerAccountId: { notIn: accounts.map((a) => a.id) } },
],
},
data: {
isActive: false,
},
}),
]
}
private async _extractTransactions(accessToken: string, accountIds: string[]) {
const accountTransactions = await Promise.all(
accountIds.map((accountId) =>
SharedUtil.paginate({
pageSize: 1000, // TODO: Check with Teller on max page size
fetchData: async () => {
const transactions = await SharedUtil.withRetry(
() =>
this.teller.getTransactions({
accountId,
accessToken: accessToken,
}),
{
maxRetries: 3,
}
)
return transactions
},
})
)
)
return accountTransactions.flat()
}
private _loadTransactions(
connection: Connection,
{
transactions,
transactionsDateRange,
}: Pick<TellerData, 'transactions' | 'transactionsDateRange'>
) {
if (!transactions.length) return []
const txnUpsertQueries = _.chunk(transactions, 1_000).map((chunk) => {
return this.prisma.$executeRaw`
INSERT INTO transaction (account_id, teller_transaction_id, date, name, amount, pending, currency_code, merchant_name, teller_type, teller_category)
VALUES
${Prisma.join(
chunk.map((tellerTransaction) => {
const {
id,
account_id,
description,
amount,
status,
type,
details,
date,
} = tellerTransaction
return Prisma.sql`(
(SELECT id FROM account WHERE account_connection_id = ${
connection.id
} AND teller_account_id = ${account_id.toString()}),
${id},
${date}::date,
${[description].filter(Boolean).join(' ')},
${DbUtil.toDecimal(-amount)},
${status === 'pending'},
${'USD'},
${details.counterparty.name ?? ''},
${type},
${details.category ?? ''},
)`
})
)}
ON CONFLICT (teller_transaction_id) DO UPDATE
SET
name = EXCLUDED.name,
amount = EXCLUDED.amount,
pending = EXCLUDED.pending,
merchant_name = EXCLUDED.merchant_name,
teller_type = EXCLUDED.teller_type,
teller_category = EXCLUDED.teller_category;
`
})
return [
// upsert transactions
...txnUpsertQueries,
// delete teller-specific transactions that are no longer in teller
this.prisma.transaction.deleteMany({
where: {
account: {
accountConnectionId: connection.id,
},
AND: [
{ tellerTransactionId: { not: null } },
{ tellerTransactionId: { notIn: transactions.map((t) => `${t.id}`) } },
],
date: {
gte: transactionsDateRange.start.startOf('day').toJSDate(),
lte: transactionsDateRange.end.endOf('day').toJSDate(),
},
},
}),
]
}
}

View file

@ -1,5 +1,14 @@
import type { Logger } from 'winston' import type { Logger } from 'winston'
import type { AccountConnection, PrismaClient, User } from '@prisma/client' import type { AccountConnection, PrismaClient, User } from '@prisma/client'
import type { IInstitutionProvider } from '../../institution'
import type {
AccountConnectionSyncEvent,
IAccountConnectionProvider,
} from '../../account-connection'
import { SharedUtil } 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 } from '@maybe-finance/teller-api'
export interface ITellerConnect { export interface ITellerConnect {
@ -11,12 +20,107 @@ export interface ITellerConnect {
): Promise<{ link: string }> ): Promise<{ link: string }>
} }
export class TellerService { export class TellerService implements IAccountConnectionProvider, IInstitutionProvider {
constructor( constructor(
private readonly logger: Logger, private readonly logger: Logger,
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly teller: TellerApi, private readonly teller: TellerApi,
private readonly etl: IETL<AccountConnection>,
private readonly crypto: CryptoService,
private readonly webhookUrl: string | Promise<string>, private readonly webhookUrl: string | Promise<string>,
private readonly testMode: boolean private readonly testMode: boolean
) {} ) {}
async sync(connection: AccountConnection, options?: SyncConnectionOptions) {
if (options && options.type !== 'teller') throw new Error('invalid sync options')
await etl(this.etl, connection)
}
async onSyncEvent(connection: AccountConnection, event: AccountConnectionSyncEvent) {
switch (event.type) {
case 'success': {
await this.prisma.accountConnection.update({
where: { id: connection.id },
data: {
status: 'OK',
},
})
break
}
case 'error': {
const { error } = event
await this.prisma.accountConnection.update({
where: { id: connection.id },
data: {
status: 'ERROR',
tellerError: ErrorUtil.isTellerError(error)
? (error.response.data as any)
: undefined,
},
})
break
}
}
}
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,
})
this.logger.info(`Item ${connection.tellerAccountId} removed`)
}
}
async getInstitutions() {
const tellerInstitutions = await SharedUtil.paginate({
pageSize: 500,
delay:
process.env.NODE_ENV !== 'production'
? {
onDelay: (message: string) => this.logger.debug(message),
milliseconds: 7_000, // Sandbox rate limited at 10 calls / minute
}
: undefined,
fetchData: (offset, count) =>
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})`
)
return data.institutions
}),
{
maxRetries: 3,
onError: (error, attempt) => {
this.logger.error(
`Teller fetch institutions request failed attempt=${attempt} offset=${offset} count=${count}`,
{ error: ErrorUtil.parseError(error) }
)
return !ErrorUtil.isTellerError(error) || error.response.status >= 500
},
}
),
})
return _.uniqBy(tellerInstitutions, (i) => i.id).map((tellerInstitution) => {
const { id, name } = tellerInstitution
return {
providerId: id,
name,
url: undefined,
logo: `https://teller.io/images/banks/${id}.jpg}`,
primaryColor: undefined,
oauth: undefined,
data: tellerInstitution,
}
})
}
} }

View file

@ -41,6 +41,7 @@ export type SyncConnectionOptions =
products?: Array<'transactions' | 'investment-transactions' | 'holdings' | 'liabilities'> products?: Array<'transactions' | 'investment-transactions' | 'holdings' | 'liabilities'>
} }
| { type: 'finicity'; initialSync?: boolean } | { type: 'finicity'; initialSync?: boolean }
| { type: 'teller'; initialSync?: boolean }
export type SyncConnectionQueueJobData = { export type SyncConnectionQueueJobData = {
accountConnectionId: AccountConnection['id'] accountConnectionId: AccountConnection['id']

View file

@ -32,6 +32,15 @@ export function isPlaidError(err: unknown): err is SharedType.AxiosPlaidError {
return 'error_type' in data && 'error_code' in data && 'error_message' in data return 'error_type' in data && 'error_code' in data && 'error_message' in data
} }
export function isTellerError(err: unknown): err is SharedType.AxiosTellerError {
if (!err) return false
if (!axios.isAxiosError(err)) return false
if (typeof err.response?.data !== 'object') return false
const { data } = err.response
return 'code' in data.error && 'message' in data.error
}
export function parseError(error: unknown): SharedType.ParsedError { export function parseError(error: unknown): SharedType.ParsedError {
if (isPlaidError(error)) { if (isPlaidError(error)) {
return parsePlaidError(error) return parsePlaidError(error)

View file

@ -2,6 +2,7 @@ export * as AuthUtil from './auth-utils'
export * as DbUtil from './db-utils' export * as DbUtil from './db-utils'
export * as FinicityUtil from './finicity-utils' export * as FinicityUtil from './finicity-utils'
export * as PlaidUtil from './plaid-utils' export * as PlaidUtil from './plaid-utils'
export * as TellerUtil from './teller-utils'
export * as ErrorUtil from './error-utils' export * as ErrorUtil from './error-utils'
// All "generic" server utils grouped here // All "generic" server utils grouped here

View file

@ -0,0 +1,63 @@
import {
Prisma,
AccountCategory,
AccountType,
type AccountClassification,
type Account,
} from '@prisma/client'
import type { TellerTypes } from '@maybe-finance/teller-api'
import { Duration } from 'luxon'
/**
* Update this with the max window that Teller supports
*/
export const TELLER_WINDOW_MAX = Duration.fromObject({ years: 1 })
export function getAccountBalanceData(
{ balances, currency }: Pick<TellerTypes.AccountWithBalances, 'balances' | 'currency'>,
classification: AccountClassification
): Pick<
Account,
| 'currentBalanceProvider'
| 'currentBalanceStrategy'
| 'availableBalanceProvider'
| '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
),
currentBalanceStrategy: 'current',
availableBalanceProvider: new Prisma.Decimal(
balances.available ? sign * Number(balances.available) : 0
),
availableBalanceStrategy: 'available',
currencyCode: currency,
}
}
export function getType(type: TellerTypes.AccountTypes): AccountType {
switch (type) {
case 'depository':
return AccountType.DEPOSITORY
case 'credit':
return AccountType.CREDIT
default:
return AccountType.OTHER_ASSET
}
}
export function tellerTypesToCategory(tellerType: TellerTypes.AccountTypes): AccountCategory {
switch (tellerType) {
case 'depository':
return AccountCategory.cash
case 'credit':
return AccountCategory.credit
default:
return AccountCategory.other
}
}

View file

@ -1,6 +1,7 @@
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import type { PlaidError } from 'plaid' import type { PlaidError } from 'plaid'
import type { AxiosError } from 'axios' import type { AxiosError } from 'axios'
import type { TellerTypes } from '@maybe-finance/teller-api'
import type { Contexts, Primitive } from '@sentry/types' import type { Contexts, Primitive } from '@sentry/types'
import type DecimalJS from 'decimal.js' import type DecimalJS from 'decimal.js'
import type { O } from 'ts-toolbelt' import type { O } from 'ts-toolbelt'
@ -79,6 +80,11 @@ export type ParsedError = {
export type AxiosPlaidError = O.Required<AxiosError<PlaidError>, 'response' | 'config'> export type AxiosPlaidError = O.Required<AxiosError<PlaidError>, 'response' | 'config'>
export type AxiosTellerError = O.Required<
AxiosError<TellerTypes.TellerError>,
'response' | 'config'
>
export type StatusPageResponse = { export type StatusPageResponse = {
page?: { page?: {
id?: string id?: string

View file

@ -9,6 +9,13 @@ import type {
DeleteAccountResponse, DeleteAccountResponse,
GetAccountDetailsResponse, GetAccountDetailsResponse,
GetInstitutionsResponse, GetInstitutionsResponse,
AuthenticatedRequest,
GetAccountRequest,
DeleteAccountRequest,
GetAccountDetailsRequest,
GetAccountBalancesRequest,
GetTransactionsRequest,
GetTransactionRequest,
} from './types' } from './types'
import axios from 'axios' import axios from 'axios'
import * as fs from 'fs' import * as fs from 'fs'
@ -26,8 +33,8 @@ export class TellerApi {
* https://teller.io/docs/api/accounts * https://teller.io/docs/api/accounts
*/ */
async getAccounts(): Promise<GetAccountsResponse> { async getAccounts({ accessToken }: AuthenticatedRequest): Promise<GetAccountsResponse> {
return this.get<GetAccountsResponse>(`/accounts`) return this.get<GetAccountsResponse>(`/accounts`, accessToken)
} }
/** /**
@ -36,8 +43,8 @@ export class TellerApi {
* https://teller.io/docs/api/accounts * https://teller.io/docs/api/accounts
*/ */
async getAccount(accountId: string): Promise<GetAccountResponse> { async getAccount({ accountId, accessToken }: GetAccountRequest): Promise<GetAccountResponse> {
return this.get<GetAccountResponse>(`/accounts/${accountId}`) return this.get<GetAccountResponse>(`/accounts/${accountId}`, accessToken)
} }
/** /**
@ -46,8 +53,11 @@ export class TellerApi {
* https://teller.io/docs/api/accounts * https://teller.io/docs/api/accounts
*/ */
async deleteAccount(accountId: string): Promise<DeleteAccountResponse> { async deleteAccount({
return this.delete<DeleteAccountResponse>(`/accounts/${accountId}`) accountId,
accessToken,
}: DeleteAccountRequest): Promise<DeleteAccountResponse> {
return this.delete<DeleteAccountResponse>(`/accounts/${accountId}`, accessToken)
} }
/** /**
@ -56,8 +66,11 @@ export class TellerApi {
* https://teller.io/docs/api/account/details * https://teller.io/docs/api/account/details
*/ */
async getAccountDetails(accountId: string): Promise<GetAccountDetailsResponse> { async getAccountDetails({
return this.get<GetAccountDetailsResponse>(`/accounts/${accountId}/details`) accountId,
accessToken,
}: GetAccountDetailsRequest): Promise<GetAccountDetailsResponse> {
return this.get<GetAccountDetailsResponse>(`/accounts/${accountId}/details`, accessToken)
} }
/** /**
@ -66,8 +79,11 @@ export class TellerApi {
* https://teller.io/docs/api/account/balances * https://teller.io/docs/api/account/balances
*/ */
async getAccountBalances(accountId: string): Promise<GetAccountBalancesResponse> { async getAccountBalances({
return this.get<GetAccountBalancesResponse>(`/accounts/${accountId}/balances`) accountId,
accessToken,
}: GetAccountBalancesRequest): Promise<GetAccountBalancesResponse> {
return this.get<GetAccountBalancesResponse>(`/accounts/${accountId}/balances`, accessToken)
} }
/** /**
@ -76,8 +92,11 @@ export class TellerApi {
* https://teller.io/docs/api/transactions * https://teller.io/docs/api/transactions
*/ */
async getTransactions(accountId: string): Promise<GetTransactionsResponse> { async getTransactions({
return this.get<GetTransactionsResponse>(`/accounts/${accountId}/transactions`) accountId,
accessToken,
}: GetTransactionsRequest): Promise<GetTransactionsResponse> {
return this.get<GetTransactionsResponse>(`/accounts/${accountId}/transactions`, accessToken)
} }
/** /**
@ -86,12 +105,14 @@ export class TellerApi {
* https://teller.io/docs/api/transactions * https://teller.io/docs/api/transactions
*/ */
async getTransaction( async getTransaction({
accountId: string, accountId,
transactionId: string transactionId,
): Promise<GetTransactionResponse> { accessToken,
}: GetTransactionRequest): Promise<GetTransactionResponse> {
return this.get<GetTransactionResponse>( return this.get<GetTransactionResponse>(
`/accounts/${accountId}/transactions/${transactionId}` `/accounts/${accountId}/transactions/${transactionId}`,
accessToken
) )
} }
@ -101,21 +122,21 @@ export class TellerApi {
* https://teller.io/docs/api/identity * https://teller.io/docs/api/identity
*/ */
async getIdentity(): Promise<GetIdentityResponse> { async getIdentity({ accessToken }: AuthenticatedRequest): Promise<GetIdentityResponse> {
return this.get<GetIdentityResponse>(`/identity`) return this.get<GetIdentityResponse>(`/identity`, accessToken)
} }
/** /**
* Get list of supported institutions * Get list of supported institutions, access token not needed
* *
* https://teller.io/docs/api/identity * https://teller.io/docs/api/identity
*/ */
async getInstitutions(): Promise<GetInstitutionsResponse> { async getInstitutions(): Promise<GetInstitutionsResponse> {
return this.get<GetInstitutionsResponse>(`/institutions`) return this.get<GetInstitutionsResponse>(`/institutions`, '')
} }
private async getApi(): Promise<AxiosInstance> { private async getApi(accessToken: string): Promise<AxiosInstance> {
const cert = fs.readFileSync('../../../certs/teller-certificate.pem', 'utf8') const cert = fs.readFileSync('../../../certs/teller-certificate.pem', 'utf8')
const key = fs.readFileSync('../../../certs/teller-private-key.pem', 'utf8') const key = fs.readFileSync('../../../certs/teller-private-key.pem', 'utf8')
@ -133,6 +154,15 @@ export class TellerApi {
Accept: 'application/json', 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
})
} }
return this.api return this.api
@ -141,30 +171,33 @@ export class TellerApi {
/** Generic API GET request method */ /** Generic API GET request method */
private async get<TResponse>( private async get<TResponse>(
path: string, path: string,
accessToken: string,
params?: any, params?: any,
config?: AxiosRequestConfig config?: AxiosRequestConfig
): Promise<TResponse> { ): Promise<TResponse> {
const api = await this.getApi() const api = await this.getApi(accessToken)
return api.get<TResponse>(path, { params, ...config }).then(({ data }) => data) return api.get<TResponse>(path, { params, ...config }).then(({ data }) => data)
} }
/** Generic API POST request method */ /** Generic API POST request method */
private async post<TResponse>( private async post<TResponse>(
path: string, path: string,
accessToken: string,
body?: any, body?: any,
config?: AxiosRequestConfig config?: AxiosRequestConfig
): Promise<TResponse> { ): Promise<TResponse> {
const api = await this.getApi() const api = await this.getApi(accessToken)
return api.post<TResponse>(path, body, config).then(({ data }) => data) return api.post<TResponse>(path, body, config).then(({ data }) => data)
} }
/** Generic API DELETE request method */ /** Generic API DELETE request method */
private async delete<TResponse>( private async delete<TResponse>(
path: string, path: string,
accessToken: string,
params?: any, params?: any,
config?: AxiosRequestConfig config?: AxiosRequestConfig
): Promise<TResponse> { ): Promise<TResponse> {
const api = await this.getApi() const api = await this.getApi(accessToken)
return api.delete<TResponse>(path, { params, ...config }).then(({ data }) => data) return api.delete<TResponse>(path, { params, ...config }).then(({ data }) => data)
} }
} }

View file

@ -1,4 +1,5 @@
// https://teller.io/docs/api/account/balances // https://teller.io/docs/api/account/balances
import type { AuthenticatedRequest } from './authentication'
export type AccountBalance = { export type AccountBalance = {
account_id: string account_id: string
@ -11,3 +12,6 @@ export type AccountBalance = {
} }
export type GetAccountBalancesResponse = AccountBalance export type GetAccountBalancesResponse = AccountBalance
export interface GetAccountBalancesRequest extends AuthenticatedRequest {
accountId: string
}

View file

@ -1,5 +1,7 @@
// https://teller.io/docs/api/account/details // https://teller.io/docs/api/account/details
import type { AuthenticatedRequest } from './authentication'
export type AccountDetails = { export type AccountDetails = {
account_id: string account_id: string
account_number: string account_number: string
@ -15,3 +17,6 @@ export type AccountDetails = {
} }
export type GetAccountDetailsResponse = AccountDetails export type GetAccountDetailsResponse = AccountDetails
export interface GetAccountDetailsRequest extends AuthenticatedRequest {
accountId: string
}

View file

@ -1,7 +1,14 @@
// https://teller.io/docs/api/accounts // https://teller.io/docs/api/accounts
import type { AccountBalance } from './account-balance'
import type { AuthenticatedRequest } from './authentication'
export type AccountTypes = 'depository' | 'credit' export type AccountTypes = 'depository' | 'credit'
export enum AccountType {
'depository',
'credit',
}
export type DepositorySubtypes = export type DepositorySubtypes =
| 'checking' | 'checking'
| 'savings' | 'savings'
@ -42,6 +49,16 @@ interface CreditAccount extends BaseAccount {
export type Account = DepositoryAccount | CreditAccount export type Account = DepositoryAccount | CreditAccount
export type GetAccountsResponse = { accounts: Account[] } export type AccountWithBalances = Account & {
balances: AccountBalance
}
export type GetAccountsResponse = Account[]
export type GetAccountResponse = Account export type GetAccountResponse = Account
export type DeleteAccountResponse = void export type DeleteAccountResponse = void
export interface GetAccountRequest extends AuthenticatedRequest {
accountId: string
}
export type DeleteAccountRequest = GetAccountRequest

View file

@ -3,3 +3,7 @@
export type AuthenticationResponse = { export type AuthenticationResponse = {
token: string token: string
} }
export type AuthenticatedRequest = {
accessToken: string
}

View file

@ -0,0 +1,6 @@
export type TellerError = {
error: {
code: string
message: string
}
}

View file

@ -2,6 +2,7 @@ export * from './accounts'
export * from './account-balance' export * from './account-balance'
export * from './account-details' export * from './account-details'
export * from './authentication' export * from './authentication'
export * from './error'
export * from './identity' export * from './identity'
export * from './institutions' export * from './institutions'
export * from './transactions' export * from './transactions'

View file

@ -1,5 +1,7 @@
// https://teller.io/docs/api/account/transactions // https://teller.io/docs/api/account/transactions
import type { AuthenticatedRequest } from './authentication'
type DetailCategory = type DetailCategory =
| 'accommodation' | 'accommodation'
| 'advertising' | 'advertising'
@ -57,3 +59,10 @@ export type Transaction = {
export type GetTransactionsResponse = Transaction[] export type GetTransactionsResponse = Transaction[]
export type GetTransactionResponse = Transaction export type GetTransactionResponse = Transaction
export interface GetTransactionsRequest extends AuthenticatedRequest {
accountId: string
}
export interface GetTransactionRequest extends AuthenticatedRequest {
accountId: string
transactionId: string
}

View file

@ -0,0 +1,37 @@
/*
Warnings:
- A unique constraint covering the columns `[account_connection_id,teller_account_id]` on the table `account` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[teller_transaction_id]` on the table `transaction` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[teller_user_id]` on the table `user` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterEnum
ALTER TYPE "AccountConnectionType" ADD VALUE 'teller';
-- AlterTable
ALTER TABLE "account" ADD COLUMN "teller_account_id" TEXT,
ADD COLUMN "teller_type" TEXT;
-- AlterTable
ALTER TABLE "account_connection" ADD COLUMN "teller_access_token" TEXT,
ADD COLUMN "teller_account_id" TEXT,
ADD COLUMN "teller_error" JSONB,
ADD COLUMN "teller_institution_id" TEXT;
-- AlterTable
ALTER TABLE "transaction" ADD COLUMN "teller_category" TEXT,
ADD COLUMN "teller_transaction_id" TEXT,
ADD COLUMN "teller_type" TEXT;
-- AlterTable
ALTER TABLE "user" ADD COLUMN "teller_user_id" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "account_account_connection_id_teller_account_id_key" ON "account"("account_connection_id", "teller_account_id");
-- CreateIndex
CREATE UNIQUE INDEX "transaction_teller_transaction_id_key" ON "transaction"("teller_transaction_id");
-- CreateIndex
CREATE UNIQUE INDEX "user_teller_user_id_key" ON "user"("teller_user_id");

View file

@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "AccountProvider" ADD VALUE 'teller';
-- AlterTable
ALTER TABLE "account" ADD COLUMN "teller_subtype" TEXT;

View file

@ -43,6 +43,7 @@ enum AccountSyncStatus {
enum AccountConnectionType { enum AccountConnectionType {
plaid plaid
finicity finicity
teller
} }
model AccountConnection { model AccountConnection {
@ -69,6 +70,12 @@ model AccountConnection {
finicityInstitutionId String? @map("finicity_institution_id") finicityInstitutionId String? @map("finicity_institution_id")
finicityError Json? @map("finicity_error") finicityError Json? @map("finicity_error")
// teller data
tellerAccountId String? @map("teller_account_id")
tellerAccessToken String? @map("teller_access_token")
tellerInstitutionId String? @map("teller_institution_id")
tellerError Json? @map("teller_error")
accounts Account[] accounts Account[]
@@index([userId]) @@index([userId])
@ -102,6 +109,7 @@ enum AccountProvider {
user user
plaid plaid
finicity finicity
teller
} }
enum AccountBalanceStrategy { enum AccountBalanceStrategy {
@ -152,6 +160,11 @@ model Account {
finicityType String? @map("finicity_type") finicityType String? @map("finicity_type")
finicityDetail Json? @map("finicity_detail") @db.JsonB finicityDetail Json? @map("finicity_detail") @db.JsonB
// teller data
tellerAccountId String? @map("teller_account_id")
tellerType String? @map("teller_type")
tellerSubtype String? @map("teller_subtype")
// manual account data // manual account data
vehicleMeta Json? @map("vehicle_meta") @db.JsonB vehicleMeta Json? @map("vehicle_meta") @db.JsonB
propertyMeta Json? @map("property_meta") @db.JsonB propertyMeta Json? @map("property_meta") @db.JsonB
@ -172,6 +185,7 @@ model Account {
@@unique([accountConnectionId, plaidAccountId]) @@unique([accountConnectionId, plaidAccountId])
@@unique([accountConnectionId, finicityAccountId]) @@unique([accountConnectionId, finicityAccountId])
@@unique([accountConnectionId, tellerAccountId])
@@index([accountConnectionId]) @@index([accountConnectionId])
@@index([userId]) @@index([userId])
@@map("account") @@map("account")
@ -346,6 +360,11 @@ model Transaction {
finicityType String? @map("finicity_type") finicityType String? @map("finicity_type")
finicityCategorization Json? @map("finicity_categorization") @db.JsonB finicityCategorization Json? @map("finicity_categorization") @db.JsonB
// teller data
tellerTransactionId String? @unique @map("teller_transaction_id")
tellerType String? @map("teller_type")
tellerCategory String? @map("teller_category")
@@index([accountId, date]) @@index([accountId, date])
@@index([amount]) @@index([amount])
@@map("transaction") @@map("transaction")
@ -430,6 +449,9 @@ model User {
// plaid data // plaid data
plaidLinkToken String? @map("plaid_link_token") // temporary token stored to maintain state across browsers plaidLinkToken String? @map("plaid_link_token") // temporary token stored to maintain state across browsers
// teller data
tellerUserId String? @unique @map("teller_user_id")
// Onboarding / usage goals // Onboarding / usage goals
household Household? household Household?
state String? state String?