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:
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)
|
# 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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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(
|
||||||
|
|
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_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(),
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
export * from './teller.webhook'
|
export * from './teller.webhook'
|
||||||
export * from './teller.service'
|
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 { 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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']
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
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 { 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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -3,3 +3,7 @@
|
||||||
export type AuthenticationResponse = {
|
export type AuthenticationResponse = {
|
||||||
token: string
|
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-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'
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
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?
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue