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

progress on Teller ETL service

This commit is contained in:
Tyler Myracle 2024-01-15 16:56:34 -06:00
parent a777ef0f0f
commit d2a36b10c6
21 changed files with 583 additions and 27 deletions

View file

@ -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

View file

@ -43,6 +43,7 @@ import {
FinicityWebhookHandler,
PlaidWebhookHandler,
TellerService,
TellerETL,
TellerWebhookHandler,
InsightService,
SecurityPricingService,
@ -149,8 +150,10 @@ const tellerService = new TellerService(
logger.child({ service: 'TellerService' }),
prisma,
teller,
new TellerETL(logger.child({ service: 'TellerETL' }), prisma, teller),
cryptoService,
getTellerWebhookUrl(),
true
env.NX_TELLER_ENV === 'sandbox'
)
// account-connection
@ -158,6 +161,7 @@ const tellerService = new TellerService(
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
plaid: plaidService,
finicity: finicityService,
teller: tellerService,
})
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_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(),

View file

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

View file

@ -0,0 +1,267 @@
import type { AccountConnection, PrismaClient } from '@prisma/client'
import type { Logger } from 'winston'
import { SharedUtil, AccountUtil, type SharedType } from '@maybe-finance/shared'
import type { FinicityApi, FinicityTypes } from '@maybe-finance/finicity-api'
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
import { DbUtil, TellerUtil, type IETL } 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.Account[]
transactions: TellerTypes.Transaction[]
transactionsDateRange: SharedType.DateRange<DateTime>
}
type Connection = Pick<AccountConnection, 'id' | 'userId' | 'tellerInstitutionId'>
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'>
) {}
async extract(connection: Connection): Promise<TellerRawData> {
if (!connection.tellerInstitutionId) {
throw new Error(`connection ${connection.id} is missing tellerInstitutionId`)
}
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(user.tellerUserId)
const transactions = await this._extractTransactions(
user.tellerUserId,
accounts.map((a) => a.id),
transactionsDateRange
)
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(tellerUserId: string) {
const { accounts } = await this.teller.getAccounts({ accessToken: undefined })
return accounts.filter(
(a) => a.institutionLoginId.toString() === institutionLoginId && a.currency === 'USD'
)
}
private _loadAccounts(connection: Connection, { accounts }: Pick<TellerData, 'accounts'>) {
return [
// upsert accounts
...accounts.map((tellerAccount) => {
return this.prisma.account.upsert({
where: {
accountConnectionId_tellerAccountId: {
accountConnectionId: connection.id,
tellerAccountId: tellerAccount.id,
},
},
create: {
type: TellerUtil.getType(tellerAccount.type),
provider: 'teller',
categoryProvider: PlaidUtil.plaidTypesToCategory(plaidAccount.type),
subcategoryProvider: plaidAccount.subtype ?? 'other',
accountConnectionId: connection.id,
plaidAccountId: plaidAccount.account_id,
name: tellerAccount.name,
plaidType: tellerAccount.type,
plaidSubtype: tellerAccount.subtype,
mask: plaidAccount.mask,
...PlaidUtil.getAccountBalanceData(
plaidAccount.balances,
plaidAccount.type
),
},
update: {
type: TellerUtil.getType(tellerAccount.type),
categoryProvider: PlaidUtil.plaidTypesToCategory(tellerAccount.type),
subcategoryProvider: tellerAccount.subtype ?? 'other',
plaidType: tellerAccount.type,
plaidSubtype: tellerAccount.subtype,
..._.omit(
PlaidUtil.getAccountBalanceData(
plaidAccount.balances,
plaidAccount.type
),
['currentBalanceStrategy', 'availableBalanceStrategy']
),
},
})
}),
// any accounts that are no longer in Plaid 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(
customerId: string,
accountIds: string[],
dateRange: SharedType.DateRange<DateTime>
) {
const accountTransactions = await Promise.all(
accountIds.map((accountId) =>
SharedUtil.paginate({
pageSize: 1000, // https://api-reference.finicity.com/#/rest/api-endpoints/transactions/get-customer-account-transactions
fetchData: async (offset, count) => {
const transactions = await SharedUtil.withRetry(
() =>
this.teller.getTransactions({
accountId,
accessToken: undefined,
fromDate: dateRange.start.toUnixInteger(),
toDate: dateRange.end.toUnixInteger(),
start: offset + 1,
limit: count,
}),
{
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 { 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(
`Plaid fetch institutions request failed attempt=${attempt} offset=${offset} count=${count}`,
{ error: ErrorUtil.parseError(error) }
)
return !ErrorUtil.isPlaidError(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'>
}
| { type: 'finicity'; initialSync?: boolean }
| { type: 'teller'; initialSync?: boolean }
export type SyncConnectionQueueJobData = {
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
}
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)

View file

@ -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

View file

@ -0,0 +1,30 @@
import { AccountCategory, AccountType } 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 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 { 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

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -1,7 +1,13 @@
// https://teller.io/docs/api/accounts
import type { AuthenticatedRequest } from './authentication'
export type AccountTypes = 'depository' | 'credit'
export enum AccountType {
'depository',
'credit',
}
export type DepositorySubtypes =
| 'checking'
| 'savings'
@ -45,3 +51,9 @@ export type Account = DepositoryAccount | CreditAccount
export type GetAccountsResponse = { accounts: Account[] }
export type GetAccountResponse = Account
export type DeleteAccountResponse = void
export interface GetAccountRequest extends AuthenticatedRequest {
accountId: string
}
export type DeleteAccountRequest = GetAccountRequest

View file

@ -3,3 +3,7 @@
export type AuthenticationResponse = {
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-details'
export * from './authentication'
export * from './error'
export * from './identity'
export * from './institutions'
export * from './transactions'

View file

@ -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
}

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

@ -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])
@ -152,6 +159,10 @@ 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")
// manual account data
vehicleMeta Json? @map("vehicle_meta") @db.JsonB
propertyMeta Json? @map("property_meta") @db.JsonB
@ -172,6 +183,7 @@ model Account {
@@unique([accountConnectionId, plaidAccountId])
@@unique([accountConnectionId, finicityAccountId])
@@unique([accountConnectionId, tellerAccountId])
@@index([accountConnectionId])
@@index([userId])
@@map("account")
@ -346,6 +358,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 +447,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?