diff --git a/.env.example b/.env.example index fb329150fc9..8c6641d2e72 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/server/src/app/lib/endpoint.ts b/apps/server/src/app/lib/endpoint.ts index 01856bc3753..c0341afadfa 100644 --- a/apps/server/src/app/lib/endpoint.ts +++ b/apps/server/src/app/lib/endpoint.ts @@ -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( diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index e69f5f4d592..b7d6adf4b1f 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -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(), diff --git a/libs/server/features/src/providers/teller/index.ts b/libs/server/features/src/providers/teller/index.ts index 3f35c0cf4b0..00a28837a67 100644 --- a/libs/server/features/src/providers/teller/index.ts +++ b/libs/server/features/src/providers/teller/index.ts @@ -1,2 +1,3 @@ export * from './teller.webhook' export * from './teller.service' +export * from './teller.etl' diff --git a/libs/server/features/src/providers/teller/teller.etl.ts b/libs/server/features/src/providers/teller/teller.etl.ts new file mode 100644 index 00000000000..b1491a357b2 --- /dev/null +++ b/libs/server/features/src/providers/teller/teller.etl.ts @@ -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 +} + +export type TellerData = { + accounts: TellerTypes.Account[] + transactions: TellerTypes.Transaction[] + transactionsDateRange: SharedType.DateRange +} + +type Connection = Pick + +export class TellerETL implements IETL { + public constructor( + private readonly logger: Logger, + private readonly prisma: PrismaClient, + private readonly teller: Pick + ) {} + + async extract(connection: Connection): Promise { + 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 { + return { + ...data, + } + } + + async load(connection: Connection, data: TellerData): Promise { + 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) { + 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 + ) { + 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 + ) { + 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(), + }, + }, + }), + ] + } +} diff --git a/libs/server/features/src/providers/teller/teller.service.ts b/libs/server/features/src/providers/teller/teller.service.ts index de393b22d35..bc6a42372f8 100644 --- a/libs/server/features/src/providers/teller/teller.service.ts +++ b/libs/server/features/src/providers/teller/teller.service.ts @@ -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, + private readonly crypto: CryptoService, private readonly webhookUrl: string | Promise, 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, + } + }) + } } diff --git a/libs/server/shared/src/services/queue.service.ts b/libs/server/shared/src/services/queue.service.ts index 2e6c2160ba2..319fef92f0f 100644 --- a/libs/server/shared/src/services/queue.service.ts +++ b/libs/server/shared/src/services/queue.service.ts @@ -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'] diff --git a/libs/server/shared/src/utils/error-utils.ts b/libs/server/shared/src/utils/error-utils.ts index 3366b3fbee6..65cefc6a7a6 100644 --- a/libs/server/shared/src/utils/error-utils.ts +++ b/libs/server/shared/src/utils/error-utils.ts @@ -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) diff --git a/libs/server/shared/src/utils/index.ts b/libs/server/shared/src/utils/index.ts index bad4dc185c8..4ba6606dd6b 100644 --- a/libs/server/shared/src/utils/index.ts +++ b/libs/server/shared/src/utils/index.ts @@ -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 diff --git a/libs/server/shared/src/utils/teller-utils.ts b/libs/server/shared/src/utils/teller-utils.ts new file mode 100644 index 00000000000..58ce8b884d7 --- /dev/null +++ b/libs/server/shared/src/utils/teller-utils.ts @@ -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 + } +} diff --git a/libs/shared/src/types/general-types.ts b/libs/shared/src/types/general-types.ts index 97117f0181d..30a28824be4 100644 --- a/libs/shared/src/types/general-types.ts +++ b/libs/shared/src/types/general-types.ts @@ -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, 'response' | 'config'> +export type AxiosTellerError = O.Required< + AxiosError, + 'response' | 'config' +> + export type StatusPageResponse = { page?: { id?: string diff --git a/libs/teller-api/src/teller-api.ts b/libs/teller-api/src/teller-api.ts index 466709714f3..ca3a180a9c5 100644 --- a/libs/teller-api/src/teller-api.ts +++ b/libs/teller-api/src/teller-api.ts @@ -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 { - return this.get(`/accounts`) + async getAccounts({ accessToken }: AuthenticatedRequest): Promise { + return this.get(`/accounts`, accessToken) } /** @@ -36,8 +43,8 @@ export class TellerApi { * https://teller.io/docs/api/accounts */ - async getAccount(accountId: string): Promise { - return this.get(`/accounts/${accountId}`) + async getAccount({ accountId, accessToken }: GetAccountRequest): Promise { + return this.get(`/accounts/${accountId}`, accessToken) } /** @@ -46,8 +53,11 @@ export class TellerApi { * https://teller.io/docs/api/accounts */ - async deleteAccount(accountId: string): Promise { - return this.delete(`/accounts/${accountId}`) + async deleteAccount({ + accountId, + accessToken, + }: DeleteAccountRequest): Promise { + return this.delete(`/accounts/${accountId}`, accessToken) } /** @@ -56,8 +66,11 @@ export class TellerApi { * https://teller.io/docs/api/account/details */ - async getAccountDetails(accountId: string): Promise { - return this.get(`/accounts/${accountId}/details`) + async getAccountDetails({ + accountId, + accessToken, + }: GetAccountDetailsRequest): Promise { + return this.get(`/accounts/${accountId}/details`, accessToken) } /** @@ -66,8 +79,11 @@ export class TellerApi { * https://teller.io/docs/api/account/balances */ - async getAccountBalances(accountId: string): Promise { - return this.get(`/accounts/${accountId}/balances`) + async getAccountBalances({ + accountId, + accessToken, + }: GetAccountBalancesRequest): Promise { + return this.get(`/accounts/${accountId}/balances`, accessToken) } /** @@ -76,8 +92,11 @@ export class TellerApi { * https://teller.io/docs/api/transactions */ - async getTransactions(accountId: string): Promise { - return this.get(`/accounts/${accountId}/transactions`) + async getTransactions({ + accountId, + accessToken, + }: GetTransactionsRequest): Promise { + return this.get(`/accounts/${accountId}/transactions`, accessToken) } /** @@ -86,12 +105,14 @@ export class TellerApi { * https://teller.io/docs/api/transactions */ - async getTransaction( - accountId: string, - transactionId: string - ): Promise { + async getTransaction({ + accountId, + transactionId, + accessToken, + }: GetTransactionRequest): Promise { return this.get( - `/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 { - return this.get(`/identity`) + async getIdentity({ accessToken }: AuthenticatedRequest): Promise { + return this.get(`/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 { - return this.get(`/institutions`) + return this.get(`/institutions`, '') } - private async getApi(): Promise { + private async getApi(accessToken: string): Promise { 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( path: string, + accessToken: string, params?: any, config?: AxiosRequestConfig ): Promise { - const api = await this.getApi() + const api = await this.getApi(accessToken) return api.get(path, { params, ...config }).then(({ data }) => data) } /** Generic API POST request method */ private async post( path: string, + accessToken: string, body?: any, config?: AxiosRequestConfig ): Promise { - const api = await this.getApi() + const api = await this.getApi(accessToken) return api.post(path, body, config).then(({ data }) => data) } /** Generic API DELETE request method */ private async delete( path: string, + accessToken: string, params?: any, config?: AxiosRequestConfig ): Promise { - const api = await this.getApi() + const api = await this.getApi(accessToken) return api.delete(path, { params, ...config }).then(({ data }) => data) } } diff --git a/libs/teller-api/src/types/account-balance.ts b/libs/teller-api/src/types/account-balance.ts index 01f0a944de3..d2e21193d1b 100644 --- a/libs/teller-api/src/types/account-balance.ts +++ b/libs/teller-api/src/types/account-balance.ts @@ -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 +} diff --git a/libs/teller-api/src/types/account-details.ts b/libs/teller-api/src/types/account-details.ts index 3dc47ed0b50..fb6e840595a 100644 --- a/libs/teller-api/src/types/account-details.ts +++ b/libs/teller-api/src/types/account-details.ts @@ -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 +} diff --git a/libs/teller-api/src/types/accounts.ts b/libs/teller-api/src/types/accounts.ts index 8faad5c7240..e3ae90754ff 100644 --- a/libs/teller-api/src/types/accounts.ts +++ b/libs/teller-api/src/types/accounts.ts @@ -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 diff --git a/libs/teller-api/src/types/authentication.ts b/libs/teller-api/src/types/authentication.ts index 1f45b91a624..b2826a8156c 100644 --- a/libs/teller-api/src/types/authentication.ts +++ b/libs/teller-api/src/types/authentication.ts @@ -3,3 +3,7 @@ export type AuthenticationResponse = { token: string } + +export type AuthenticatedRequest = { + accessToken: string +} diff --git a/libs/teller-api/src/types/error.ts b/libs/teller-api/src/types/error.ts new file mode 100644 index 00000000000..33b702c24dc --- /dev/null +++ b/libs/teller-api/src/types/error.ts @@ -0,0 +1,6 @@ +export type TellerError = { + error: { + code: string + message: string + } +} diff --git a/libs/teller-api/src/types/index.ts b/libs/teller-api/src/types/index.ts index f3b60309ffa..ca90d347c70 100644 --- a/libs/teller-api/src/types/index.ts +++ b/libs/teller-api/src/types/index.ts @@ -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' diff --git a/libs/teller-api/src/types/transactions.ts b/libs/teller-api/src/types/transactions.ts index 9c7dc07b809..1d482aa88c4 100644 --- a/libs/teller-api/src/types/transactions.ts +++ b/libs/teller-api/src/types/transactions.ts @@ -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 +} diff --git a/prisma/migrations/20240115222631_add_fields_for_teller/migration.sql b/prisma/migrations/20240115222631_add_fields_for_teller/migration.sql new file mode 100644 index 00000000000..e05cf085099 --- /dev/null +++ b/prisma/migrations/20240115222631_add_fields_for_teller/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 587a274cae5..160ea264af7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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?