mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
Merge pull request #98 from tmyracle/teller-p2-service
Feature: Support Teller Part 2: ETL Service
This commit is contained in:
commit
e4043cd090
25 changed files with 653 additions and 29 deletions
|
@ -20,6 +20,7 @@ NX_FINICITY_PARTNER_SECRET=
|
|||
# Teller API keys (https://teller.io)
|
||||
NX_TELLER_SIGNING_SECRET=
|
||||
NX_TELLER_APP_ID=
|
||||
NX_TELLER_ENV=sandbox
|
||||
|
||||
# Email credentials
|
||||
NX_POSTMARK_FROM_ADDRESS=account@example.com
|
||||
|
|
|
@ -43,6 +43,7 @@ import {
|
|||
FinicityWebhookHandler,
|
||||
PlaidWebhookHandler,
|
||||
TellerService,
|
||||
TellerETL,
|
||||
TellerWebhookHandler,
|
||||
InsightService,
|
||||
SecurityPricingService,
|
||||
|
@ -53,7 +54,6 @@ import {
|
|||
ProjectionCalculator,
|
||||
StripeWebhookHandler,
|
||||
} from '@maybe-finance/server/features'
|
||||
import { SharedType } from '@maybe-finance/shared'
|
||||
import prisma from './prisma'
|
||||
import plaid, { getPlaidWebhookUrl } from './plaid'
|
||||
import finicity, { getFinicityTxPushUrl, getFinicityWebhookUrl } from './finicity'
|
||||
|
@ -149,8 +149,10 @@ const tellerService = new TellerService(
|
|||
logger.child({ service: 'TellerService' }),
|
||||
prisma,
|
||||
teller,
|
||||
new TellerETL(logger.child({ service: 'TellerETL' }), prisma, teller, cryptoService),
|
||||
cryptoService,
|
||||
getTellerWebhookUrl(),
|
||||
true
|
||||
env.NX_TELLER_ENV === 'sandbox'
|
||||
)
|
||||
|
||||
// account-connection
|
||||
|
@ -158,6 +160,7 @@ const tellerService = new TellerService(
|
|||
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
|
||||
plaid: plaidService,
|
||||
finicity: finicityService,
|
||||
teller: tellerService,
|
||||
})
|
||||
|
||||
const transactionStrategy = new TransactionBalanceSyncStrategy(
|
||||
|
|
|
@ -43,6 +43,7 @@ const envSchema = z.object({
|
|||
|
||||
NX_TELLER_SIGNING_SECRET: 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_ENV: z.string().optional(),
|
||||
|
|
|
@ -28,6 +28,8 @@ import {
|
|||
LoanBalanceSyncStrategy,
|
||||
PlaidETL,
|
||||
PlaidService,
|
||||
TellerETL,
|
||||
TellerService,
|
||||
SecurityPricingProcessor,
|
||||
SecurityPricingService,
|
||||
TransactionBalanceSyncStrategy,
|
||||
|
@ -55,6 +57,7 @@ import logger from './logger'
|
|||
import prisma from './prisma'
|
||||
import plaid from './plaid'
|
||||
import finicity from './finicity'
|
||||
import teller from './teller'
|
||||
import postmark from './postmark'
|
||||
import stripe from './stripe'
|
||||
import env from '../../env'
|
||||
|
@ -124,11 +127,22 @@ const finicityService = new FinicityService(
|
|||
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
|
||||
|
||||
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
|
||||
plaid: plaidService,
|
||||
finicity: finicityService,
|
||||
teller: tellerService,
|
||||
})
|
||||
|
||||
const transactionStrategy = new TransactionBalanceSyncStrategy(
|
||||
|
|
5
apps/workers/src/app/lib/teller.ts
Normal file
5
apps/workers/src/app/lib/teller.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { TellerApi } from '@maybe-finance/teller-api'
|
||||
|
||||
const teller = new TellerApi()
|
||||
|
||||
export default teller
|
|
@ -17,6 +17,7 @@ const envSchema = z.object({
|
|||
|
||||
NX_TELLER_SIGNING_SECRET: 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_ENV: z.string().optional(),
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
export * from './teller.webhook'
|
||||
export * from './teller.service'
|
||||
export * from './teller.etl'
|
||||
|
|
271
libs/server/features/src/providers/teller/teller.etl.ts
Normal file
271
libs/server/features/src/providers/teller/teller.etl.ts
Normal 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(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,5 +1,14 @@
|
|||
import type { Logger } from 'winston'
|
||||
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'
|
||||
|
||||
export interface ITellerConnect {
|
||||
|
@ -11,12 +20,107 @@ export interface ITellerConnect {
|
|||
): Promise<{ link: string }>
|
||||
}
|
||||
|
||||
export class TellerService {
|
||||
export class TellerService implements IAccountConnectionProvider, IInstitutionProvider {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly teller: TellerApi,
|
||||
private readonly etl: IETL<AccountConnection>,
|
||||
private readonly crypto: CryptoService,
|
||||
private readonly webhookUrl: string | Promise<string>,
|
||||
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,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,6 +41,7 @@ export type SyncConnectionOptions =
|
|||
products?: Array<'transactions' | 'investment-transactions' | 'holdings' | 'liabilities'>
|
||||
}
|
||||
| { type: 'finicity'; initialSync?: boolean }
|
||||
| { type: 'teller'; initialSync?: boolean }
|
||||
|
||||
export type SyncConnectionQueueJobData = {
|
||||
accountConnectionId: AccountConnection['id']
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
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 {
|
||||
if (isPlaidError(error)) {
|
||||
return parsePlaidError(error)
|
||||
|
|
|
@ -2,6 +2,7 @@ export * as AuthUtil from './auth-utils'
|
|||
export * as DbUtil from './db-utils'
|
||||
export * as FinicityUtil from './finicity-utils'
|
||||
export * as PlaidUtil from './plaid-utils'
|
||||
export * as TellerUtil from './teller-utils'
|
||||
export * as ErrorUtil from './error-utils'
|
||||
|
||||
// All "generic" server utils grouped here
|
||||
|
|
63
libs/server/shared/src/utils/teller-utils.ts
Normal file
63
libs/server/shared/src/utils/teller-utils.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import type { Prisma } from '@prisma/client'
|
||||
import type { PlaidError } from 'plaid'
|
||||
import type { AxiosError } from 'axios'
|
||||
import type { TellerTypes } from '@maybe-finance/teller-api'
|
||||
import type { Contexts, Primitive } from '@sentry/types'
|
||||
import type DecimalJS from 'decimal.js'
|
||||
import type { O } from 'ts-toolbelt'
|
||||
|
@ -79,6 +80,11 @@ export type ParsedError = {
|
|||
|
||||
export type AxiosPlaidError = O.Required<AxiosError<PlaidError>, 'response' | 'config'>
|
||||
|
||||
export type AxiosTellerError = O.Required<
|
||||
AxiosError<TellerTypes.TellerError>,
|
||||
'response' | 'config'
|
||||
>
|
||||
|
||||
export type StatusPageResponse = {
|
||||
page?: {
|
||||
id?: string
|
||||
|
|
|
@ -9,6 +9,13 @@ import type {
|
|||
DeleteAccountResponse,
|
||||
GetAccountDetailsResponse,
|
||||
GetInstitutionsResponse,
|
||||
AuthenticatedRequest,
|
||||
GetAccountRequest,
|
||||
DeleteAccountRequest,
|
||||
GetAccountDetailsRequest,
|
||||
GetAccountBalancesRequest,
|
||||
GetTransactionsRequest,
|
||||
GetTransactionRequest,
|
||||
} from './types'
|
||||
import axios from 'axios'
|
||||
import * as fs from 'fs'
|
||||
|
@ -26,8 +33,8 @@ export class TellerApi {
|
|||
* https://teller.io/docs/api/accounts
|
||||
*/
|
||||
|
||||
async getAccounts(): Promise<GetAccountsResponse> {
|
||||
return this.get<GetAccountsResponse>(`/accounts`)
|
||||
async getAccounts({ accessToken }: AuthenticatedRequest): Promise<GetAccountsResponse> {
|
||||
return this.get<GetAccountsResponse>(`/accounts`, accessToken)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -36,8 +43,8 @@ export class TellerApi {
|
|||
* https://teller.io/docs/api/accounts
|
||||
*/
|
||||
|
||||
async getAccount(accountId: string): Promise<GetAccountResponse> {
|
||||
return this.get<GetAccountResponse>(`/accounts/${accountId}`)
|
||||
async getAccount({ accountId, accessToken }: GetAccountRequest): Promise<GetAccountResponse> {
|
||||
return this.get<GetAccountResponse>(`/accounts/${accountId}`, accessToken)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -46,8 +53,11 @@ export class TellerApi {
|
|||
* https://teller.io/docs/api/accounts
|
||||
*/
|
||||
|
||||
async deleteAccount(accountId: string): Promise<DeleteAccountResponse> {
|
||||
return this.delete<DeleteAccountResponse>(`/accounts/${accountId}`)
|
||||
async deleteAccount({
|
||||
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
|
||||
*/
|
||||
|
||||
async getAccountDetails(accountId: string): Promise<GetAccountDetailsResponse> {
|
||||
return this.get<GetAccountDetailsResponse>(`/accounts/${accountId}/details`)
|
||||
async getAccountDetails({
|
||||
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
|
||||
*/
|
||||
|
||||
async getAccountBalances(accountId: string): Promise<GetAccountBalancesResponse> {
|
||||
return this.get<GetAccountBalancesResponse>(`/accounts/${accountId}/balances`)
|
||||
async getAccountBalances({
|
||||
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
|
||||
*/
|
||||
|
||||
async getTransactions(accountId: string): Promise<GetTransactionsResponse> {
|
||||
return this.get<GetTransactionsResponse>(`/accounts/${accountId}/transactions`)
|
||||
async getTransactions({
|
||||
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
|
||||
*/
|
||||
|
||||
async getTransaction(
|
||||
accountId: string,
|
||||
transactionId: string
|
||||
): Promise<GetTransactionResponse> {
|
||||
async getTransaction({
|
||||
accountId,
|
||||
transactionId,
|
||||
accessToken,
|
||||
}: GetTransactionRequest): Promise<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
|
||||
*/
|
||||
|
||||
async getIdentity(): Promise<GetIdentityResponse> {
|
||||
return this.get<GetIdentityResponse>(`/identity`)
|
||||
async getIdentity({ accessToken }: AuthenticatedRequest): Promise<GetIdentityResponse> {
|
||||
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
|
||||
*/
|
||||
|
||||
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 key = fs.readFileSync('../../../certs/teller-private-key.pem', 'utf8')
|
||||
|
||||
|
@ -133,6 +154,15 @@ export class TellerApi {
|
|||
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
|
||||
|
@ -141,30 +171,33 @@ export class TellerApi {
|
|||
/** Generic API GET request method */
|
||||
private async get<TResponse>(
|
||||
path: string,
|
||||
accessToken: string,
|
||||
params?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<TResponse> {
|
||||
const api = await this.getApi()
|
||||
const api = await this.getApi(accessToken)
|
||||
return api.get<TResponse>(path, { params, ...config }).then(({ data }) => data)
|
||||
}
|
||||
|
||||
/** Generic API POST request method */
|
||||
private async post<TResponse>(
|
||||
path: string,
|
||||
accessToken: string,
|
||||
body?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<TResponse> {
|
||||
const api = await this.getApi()
|
||||
const api = await this.getApi(accessToken)
|
||||
return api.post<TResponse>(path, body, config).then(({ data }) => data)
|
||||
}
|
||||
|
||||
/** Generic API DELETE request method */
|
||||
private async delete<TResponse>(
|
||||
path: string,
|
||||
accessToken: string,
|
||||
params?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<TResponse> {
|
||||
const api = await this.getApi()
|
||||
const api = await this.getApi(accessToken)
|
||||
return api.delete<TResponse>(path, { params, ...config }).then(({ data }) => data)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
// https://teller.io/docs/api/account/balances
|
||||
import type { AuthenticatedRequest } from './authentication'
|
||||
|
||||
export type AccountBalance = {
|
||||
account_id: string
|
||||
|
@ -11,3 +12,6 @@ export type AccountBalance = {
|
|||
}
|
||||
|
||||
export type GetAccountBalancesResponse = AccountBalance
|
||||
export interface GetAccountBalancesRequest extends AuthenticatedRequest {
|
||||
accountId: string
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
// https://teller.io/docs/api/account/details
|
||||
|
||||
import type { AuthenticatedRequest } from './authentication'
|
||||
|
||||
export type AccountDetails = {
|
||||
account_id: string
|
||||
account_number: string
|
||||
|
@ -15,3 +17,6 @@ export type AccountDetails = {
|
|||
}
|
||||
|
||||
export type GetAccountDetailsResponse = AccountDetails
|
||||
export interface GetAccountDetailsRequest extends AuthenticatedRequest {
|
||||
accountId: string
|
||||
}
|
||||
|
|
|
@ -1,7 +1,14 @@
|
|||
// https://teller.io/docs/api/accounts
|
||||
import type { AccountBalance } from './account-balance'
|
||||
import type { AuthenticatedRequest } from './authentication'
|
||||
|
||||
export type AccountTypes = 'depository' | 'credit'
|
||||
|
||||
export enum AccountType {
|
||||
'depository',
|
||||
'credit',
|
||||
}
|
||||
|
||||
export type DepositorySubtypes =
|
||||
| 'checking'
|
||||
| 'savings'
|
||||
|
@ -42,6 +49,16 @@ interface CreditAccount extends BaseAccount {
|
|||
|
||||
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 DeleteAccountResponse = void
|
||||
|
||||
export interface GetAccountRequest extends AuthenticatedRequest {
|
||||
accountId: string
|
||||
}
|
||||
|
||||
export type DeleteAccountRequest = GetAccountRequest
|
||||
|
|
|
@ -3,3 +3,7 @@
|
|||
export type AuthenticationResponse = {
|
||||
token: string
|
||||
}
|
||||
|
||||
export type AuthenticatedRequest = {
|
||||
accessToken: string
|
||||
}
|
||||
|
|
6
libs/teller-api/src/types/error.ts
Normal file
6
libs/teller-api/src/types/error.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export type TellerError = {
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ export * from './accounts'
|
|||
export * from './account-balance'
|
||||
export * from './account-details'
|
||||
export * from './authentication'
|
||||
export * from './error'
|
||||
export * from './identity'
|
||||
export * from './institutions'
|
||||
export * from './transactions'
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
// https://teller.io/docs/api/account/transactions
|
||||
|
||||
import type { AuthenticatedRequest } from './authentication'
|
||||
|
||||
type DetailCategory =
|
||||
| 'accommodation'
|
||||
| 'advertising'
|
||||
|
@ -57,3 +59,10 @@ export type Transaction = {
|
|||
|
||||
export type GetTransactionsResponse = Transaction[]
|
||||
export type GetTransactionResponse = Transaction
|
||||
export interface GetTransactionsRequest extends AuthenticatedRequest {
|
||||
accountId: string
|
||||
}
|
||||
export interface GetTransactionRequest extends AuthenticatedRequest {
|
||||
accountId: string
|
||||
transactionId: string
|
||||
}
|
||||
|
|
|
@ -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");
|
|
@ -0,0 +1,5 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "AccountProvider" ADD VALUE 'teller';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "account" ADD COLUMN "teller_subtype" TEXT;
|
|
@ -43,6 +43,7 @@ enum AccountSyncStatus {
|
|||
enum AccountConnectionType {
|
||||
plaid
|
||||
finicity
|
||||
teller
|
||||
}
|
||||
|
||||
model AccountConnection {
|
||||
|
@ -69,6 +70,12 @@ model AccountConnection {
|
|||
finicityInstitutionId String? @map("finicity_institution_id")
|
||||
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[]
|
||||
|
||||
@@index([userId])
|
||||
|
@ -102,6 +109,7 @@ enum AccountProvider {
|
|||
user
|
||||
plaid
|
||||
finicity
|
||||
teller
|
||||
}
|
||||
|
||||
enum AccountBalanceStrategy {
|
||||
|
@ -152,6 +160,11 @@ model Account {
|
|||
finicityType String? @map("finicity_type")
|
||||
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
|
||||
vehicleMeta Json? @map("vehicle_meta") @db.JsonB
|
||||
propertyMeta Json? @map("property_meta") @db.JsonB
|
||||
|
@ -172,6 +185,7 @@ model Account {
|
|||
|
||||
@@unique([accountConnectionId, plaidAccountId])
|
||||
@@unique([accountConnectionId, finicityAccountId])
|
||||
@@unique([accountConnectionId, tellerAccountId])
|
||||
@@index([accountConnectionId])
|
||||
@@index([userId])
|
||||
@@map("account")
|
||||
|
@ -346,6 +360,11 @@ model Transaction {
|
|||
finicityType String? @map("finicity_type")
|
||||
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([amount])
|
||||
@@map("transaction")
|
||||
|
@ -430,6 +449,9 @@ model User {
|
|||
// plaid data
|
||||
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
|
||||
household Household?
|
||||
state String?
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue