diff --git a/.env.example b/.env.example index 0f2e063d..fb329150 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,10 @@ NX_PLAID_SECRET= NX_FINICITY_APP_KEY= NX_FINICITY_PARTNER_SECRET= +# Teller API keys (https://teller.io) +NX_TELLER_SIGNING_SECRET= +NX_TELLER_APP_ID= + # Email credentials NX_POSTMARK_FROM_ADDRESS=account@example.com NX_POSTMARK_REPLY_TO_ADDRESS=support@example.com diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index f3d5c415..67e8d549 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -4,7 +4,6 @@ about: Create a report to help us improve title: '' labels: bug assignees: '' - --- **Describe the bug** @@ -12,6 +11,7 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -24,15 +24,17 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] **Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] + +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] **Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7d..2f28cead 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -4,7 +4,6 @@ about: Suggest an idea for this project title: '' labels: '' assignees: '' - --- **Is your feature request related to a problem? Please describe.** diff --git a/.gitignore b/.gitignore index 659edc84..61a8902d 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,5 @@ Thumbs.db migrations.json # Shouldn't happen, but backup since we have a script that generates these locally -*.pem \ No newline at end of file +*.pem +certs/ diff --git a/apps/server/src/app/lib/endpoint.ts b/apps/server/src/app/lib/endpoint.ts index 7459930e..01856bc3 100644 --- a/apps/server/src/app/lib/endpoint.ts +++ b/apps/server/src/app/lib/endpoint.ts @@ -42,6 +42,8 @@ import { InstitutionProviderFactory, FinicityWebhookHandler, PlaidWebhookHandler, + TellerService, + TellerWebhookHandler, InsightService, SecurityPricingService, TransactionService, @@ -55,6 +57,7 @@ import { SharedType } from '@maybe-finance/shared' import prisma from './prisma' import plaid, { getPlaidWebhookUrl } from './plaid' import finicity, { getFinicityTxPushUrl, getFinicityWebhookUrl } from './finicity' +import teller, { getTellerWebhookUrl } from './teller' import stripe from './stripe' import postmark from './postmark' import defineAbilityFor from './ability' @@ -142,6 +145,14 @@ const finicityService = new FinicityService( env.NX_FINICITY_ENV === 'sandbox' ) +const tellerService = new TellerService( + logger.child({ service: 'TellerService' }), + prisma, + teller, + getTellerWebhookUrl(), + true +) + // account-connection const accountConnectionProviderFactory = new AccountConnectionProviderFactory({ @@ -278,6 +289,13 @@ const stripeWebhooks = new StripeWebhookHandler( stripe ) +const tellerWebhooks = new TellerWebhookHandler( + logger.child({ service: 'TellerWebhookHandler' }), + prisma, + teller, + accountConnectionService +) + // helper function for parsing JWT and loading User record // TODO: update this with roles, identity, and metadata async function getCurrentUser(jwt: NonNullable) { @@ -339,6 +357,8 @@ export async function createContext(req: Request) { finicityService, finicityWebhooks, stripeWebhooks, + tellerService, + tellerWebhooks, insightService, marketDataService, planService, diff --git a/apps/server/src/app/lib/teller.ts b/apps/server/src/app/lib/teller.ts new file mode 100644 index 00000000..a9549ed6 --- /dev/null +++ b/apps/server/src/app/lib/teller.ts @@ -0,0 +1,11 @@ +import { TellerApi } from '@maybe-finance/teller-api' +import { getWebhookUrl } from './webhook' + +const teller = new TellerApi() + +export default teller + +export async function getTellerWebhookUrl() { + const webhookUrl = await getWebhookUrl() + return `${webhookUrl}/v1/teller/webhook` +} diff --git a/apps/server/src/app/middleware/index.ts b/apps/server/src/app/middleware/index.ts index 4a8ebce8..1d9548f9 100644 --- a/apps/server/src/app/middleware/index.ts +++ b/apps/server/src/app/middleware/index.ts @@ -5,5 +5,6 @@ export * from './superjson' export * from './validate-auth-jwt' export * from './validate-plaid-jwt' export * from './validate-finicity-signature' +export * from './validate-teller-signature' export { default as maintenance } from './maintenance' export * from './identify-user' diff --git a/apps/server/src/app/middleware/validate-teller-signature.ts b/apps/server/src/app/middleware/validate-teller-signature.ts new file mode 100644 index 00000000..83ce959f --- /dev/null +++ b/apps/server/src/app/middleware/validate-teller-signature.ts @@ -0,0 +1,50 @@ +import crypto from 'crypto' +import type { RequestHandler } from 'express' +import type { TellerTypes } from '@maybe-finance/teller-api' +import env from '../../env' + +// https://teller.io/docs/api/webhooks#verifying-messages +export const validateTellerSignature: RequestHandler = (req, res, next) => { + const signatureHeader = req.headers['teller-signature'] as string | undefined + + if (!signatureHeader) { + return res.status(401).send('No Teller-Signature header found') + } + + const { timestamp, signatures } = parseTellerSignatureHeader(signatureHeader) + const threeMinutesAgo = Math.floor(Date.now() / 1000) - 3 * 60 + + if (parseInt(timestamp) < threeMinutesAgo) { + return res.status(408).send('Signature timestamp is too old') + } + + const signedMessage = `${timestamp}.${JSON.stringify(req.body as TellerTypes.WebhookData)}` + const expectedSignature = createHmacSha256(signedMessage, env.NX_TELLER_SIGNING_SECRET) + + if (!signatures.includes(expectedSignature)) { + return res.status(401).send('Invalid webhook signature') + } + + next() +} + +const parseTellerSignatureHeader = ( + header: string +): { timestamp: string; signatures: string[] } => { + const parts = header.split(',') + const timestampPart = parts.find((p) => p.startsWith('t=')) + const signatureParts = parts.filter((p) => p.startsWith('v1=')) + + if (!timestampPart) { + throw new Error('No timestamp in Teller-Signature header') + } + + const timestamp = timestampPart.split('=')[1] + const signatures = signatureParts.map((p) => p.split('=')[1]) + + return { timestamp, signatures } +} + +const createHmacSha256 = (message: string, secret: string): string => { + return crypto.createHmac('sha256', secret).update(message).digest('hex') +} diff --git a/apps/server/src/app/routes/webhooks.router.ts b/apps/server/src/app/routes/webhooks.router.ts index 958e18fe..6abbd3dd 100644 --- a/apps/server/src/app/routes/webhooks.router.ts +++ b/apps/server/src/app/routes/webhooks.router.ts @@ -1,10 +1,11 @@ import { Router } from 'express' import { z } from 'zod' import type { FinicityTypes } from '@maybe-finance/finicity-api' -import { validatePlaidJwt, validateFinicitySignature } from '../middleware' +import { validatePlaidJwt, validateFinicitySignature, validateTellerSignature } from '../middleware' import endpoint from '../lib/endpoint' import stripe from '../lib/stripe' import env from '../../env' +import type { TellerTypes } from '@maybe-finance/teller-api' const router = Router() @@ -131,4 +132,42 @@ router.post( }) ) +router.post( + '/teller/webhook', + process.env.NODE_ENV !== 'development' ? validateTellerSignature : (_req, _res, next) => next(), + endpoint.create({ + input: z + .object({ + id: z.string(), + payload: z.object({ + enrollment_id: z.string(), + reason: z.string(), + }), + timestamp: z.string(), + type: z.string(), + }) + .passthrough(), + async resolve({ input, ctx }) { + const { type, id, payload } = input + + ctx.logger.info( + `rx[teller_webhook] event eventType=${type} eventId=${id} enrollmentId=${payload.enrollment_id}` + ) + + // May contain sensitive info, only print at the debug level + ctx.logger.debug(`rx[teller_webhook] event payload`, input) + + try { + console.log('handling webhook') + await ctx.tellerWebhooks.handleWebhook(input as TellerTypes.WebhookData) + } catch (err) { + // record error but don't throw + ctx.logger.error(`[teller_webhook] error handling webhook`, err) + } + + return { status: 'ok' } + }, + }) +) + export default router diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index 78f12972..e69f5f4d 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -41,6 +41,9 @@ const envSchema = z.object({ NX_FINICITY_PARTNER_SECRET: z.string(), NX_FINICITY_ENV: z.string().default('sandbox'), + NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'), + NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'), + NX_SENTRY_DSN: z.string().optional(), NX_SENTRY_ENV: z.string().optional(), diff --git a/apps/workers/src/env.ts b/apps/workers/src/env.ts index b9632599..c41d9692 100644 --- a/apps/workers/src/env.ts +++ b/apps/workers/src/env.ts @@ -15,6 +15,9 @@ const envSchema = z.object({ NX_FINICITY_PARTNER_SECRET: z.string(), NX_FINICITY_ENV: z.string().default('sandbox'), + NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'), + NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'), + NX_SENTRY_DSN: z.string().optional(), NX_SENTRY_ENV: z.string().optional(), diff --git a/libs/server/features/src/providers/index.ts b/libs/server/features/src/providers/index.ts index 7167b51c..ffbf09d5 100644 --- a/libs/server/features/src/providers/index.ts +++ b/libs/server/features/src/providers/index.ts @@ -1,4 +1,5 @@ export * from './plaid' export * from './finicity' +export * from './teller' export * from './vehicle' export * from './property' diff --git a/libs/server/features/src/providers/teller/index.ts b/libs/server/features/src/providers/teller/index.ts new file mode 100644 index 00000000..3f35c0cf --- /dev/null +++ b/libs/server/features/src/providers/teller/index.ts @@ -0,0 +1,2 @@ +export * from './teller.webhook' +export * from './teller.service' diff --git a/libs/server/features/src/providers/teller/teller.service.ts b/libs/server/features/src/providers/teller/teller.service.ts new file mode 100644 index 00000000..de393b22 --- /dev/null +++ b/libs/server/features/src/providers/teller/teller.service.ts @@ -0,0 +1,22 @@ +import type { Logger } from 'winston' +import type { AccountConnection, PrismaClient, User } from '@prisma/client' +import type { TellerApi } from '@maybe-finance/teller-api' + +export interface ITellerConnect { + generateConnectUrl(userId: User['id'], institutionId: string): Promise<{ link: string }> + + generateFixConnectUrl( + userId: User['id'], + accountConnectionId: AccountConnection['id'] + ): Promise<{ link: string }> +} + +export class TellerService { + constructor( + private readonly logger: Logger, + private readonly prisma: PrismaClient, + private readonly teller: TellerApi, + private readonly webhookUrl: string | Promise, + private readonly testMode: boolean + ) {} +} diff --git a/libs/server/features/src/providers/teller/teller.webhook.ts b/libs/server/features/src/providers/teller/teller.webhook.ts new file mode 100644 index 00000000..97ed4a3a --- /dev/null +++ b/libs/server/features/src/providers/teller/teller.webhook.ts @@ -0,0 +1,34 @@ +import type { Logger } from 'winston' +import type { PrismaClient } from '@prisma/client' +import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api' +import type { IAccountConnectionService } from '../../account-connection' + +export interface ITellerWebhookHandler { + handleWebhook(data: TellerTypes.WebhookData): Promise +} + +export class TellerWebhookHandler implements ITellerWebhookHandler { + constructor( + private readonly logger: Logger, + private readonly prisma: PrismaClient, + private readonly teller: TellerApi, + private readonly accountConnectionService: IAccountConnectionService + ) {} + + /** + * Process Teller webhooks. These handlers should execute as quick as possible and + * long-running operations should be performed in the background. + */ + async handleWebhook(data: TellerTypes.WebhookData) { + switch (data.type) { + case 'webhook.test': { + this.logger.info('Received Teller webhook test') + break + } + default: { + this.logger.warn('Unhandled Teller webhook', { data }) + break + } + } + } +} diff --git a/libs/server/shared/src/services/crypto.service.ts b/libs/server/shared/src/services/crypto.service.ts index 9a865325..60ce93d5 100644 --- a/libs/server/shared/src/services/crypto.service.ts +++ b/libs/server/shared/src/services/crypto.service.ts @@ -1,4 +1,4 @@ -import CryptoJS from 'crypto-js' +import crypto from 'crypto' export interface ICryptoService { encrypt(plainText: string): string @@ -6,13 +6,32 @@ export interface ICryptoService { } export class CryptoService implements ICryptoService { - constructor(private readonly secret: string) {} + private key: Buffer + private ivLength = 16 // Initialization vector length. For AES, this is always 16 + + constructor(private readonly secret: string) { + // Ensure the key length is suitable for AES-256 + this.key = crypto.createHash('sha256').update(String(this.secret)).digest() + } encrypt(plainText: string) { - return CryptoJS.AES.encrypt(plainText, this.secret).toString() + const iv = crypto.randomBytes(this.ivLength) + const cipher = crypto.createCipheriv('aes-256-cbc', this.key, iv) + let encrypted = cipher.update(plainText, 'utf8', 'hex') + encrypted += cipher.final('hex') + + // Include the IV at the start of the encrypted result + return iv.toString('hex') + ':' + encrypted } decrypt(encrypted: string) { - return CryptoJS.AES.decrypt(encrypted, this.secret).toString(CryptoJS.enc.Utf8) + const textParts = encrypted.split(':') + const iv = Buffer.from(textParts.shift()!, 'hex') + const encryptedText = textParts.join(':') + const decipher = crypto.createDecipheriv('aes-256-cbc', this.key, iv) + let decrypted = decipher.update(encryptedText, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + return decrypted } } diff --git a/libs/teller-api/.eslintrc.json b/libs/teller-api/.eslintrc.json new file mode 100644 index 00000000..5626944b --- /dev/null +++ b/libs/teller-api/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/teller-api/jest.config.ts b/libs/teller-api/jest.config.ts new file mode 100644 index 00000000..eaafb159 --- /dev/null +++ b/libs/teller-api/jest.config.ts @@ -0,0 +1,16 @@ +/* eslint-disable */ +export default { + displayName: 'teller-api', + preset: '../../jest.preset.js', + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + }, + }, + testEnvironment: 'node', + transform: { + '^.+\\.[tj]sx?$': 'ts-jest', + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/libs/teller-api', +} diff --git a/libs/teller-api/src/index.ts b/libs/teller-api/src/index.ts new file mode 100644 index 00000000..c28f19a7 --- /dev/null +++ b/libs/teller-api/src/index.ts @@ -0,0 +1,2 @@ +export * from './teller-api' +export * as TellerTypes from './types' diff --git a/libs/teller-api/src/teller-api.ts b/libs/teller-api/src/teller-api.ts new file mode 100644 index 00000000..46670971 --- /dev/null +++ b/libs/teller-api/src/teller-api.ts @@ -0,0 +1,170 @@ +import type { AxiosInstance, AxiosRequestConfig } from 'axios' +import type { + GetAccountResponse, + GetAccountsResponse, + GetAccountBalancesResponse, + GetIdentityResponse, + GetTransactionResponse, + GetTransactionsResponse, + DeleteAccountResponse, + GetAccountDetailsResponse, + GetInstitutionsResponse, +} from './types' +import axios from 'axios' +import * as fs from 'fs' +import * as https from 'https' + +/** + * Basic typed mapping for Teller API + */ +export class TellerApi { + private api: AxiosInstance | null = null + + /** + * List accounts a user granted access to in Teller Connect + * + * https://teller.io/docs/api/accounts + */ + + async getAccounts(): Promise { + return this.get(`/accounts`) + } + + /** + * Get a single account by id + * + * https://teller.io/docs/api/accounts + */ + + async getAccount(accountId: string): Promise { + return this.get(`/accounts/${accountId}`) + } + + /** + * Delete the application's access to an account. Does not delete the account itself. + * + * https://teller.io/docs/api/accounts + */ + + async deleteAccount(accountId: string): Promise { + return this.delete(`/accounts/${accountId}`) + } + + /** + * Get account details for a single account + * + * https://teller.io/docs/api/account/details + */ + + async getAccountDetails(accountId: string): Promise { + return this.get(`/accounts/${accountId}/details`) + } + + /** + * Get account balances for a single account + * + * https://teller.io/docs/api/account/balances + */ + + async getAccountBalances(accountId: string): Promise { + return this.get(`/accounts/${accountId}/balances`) + } + + /** + * Get transactions for a single account + * + * https://teller.io/docs/api/transactions + */ + + async getTransactions(accountId: string): Promise { + return this.get(`/accounts/${accountId}/transactions`) + } + + /** + * Get a single transaction by id + * + * https://teller.io/docs/api/transactions + */ + + async getTransaction( + accountId: string, + transactionId: string + ): Promise { + return this.get( + `/accounts/${accountId}/transactions/${transactionId}` + ) + } + + /** + * Get identity for a single account + * + * https://teller.io/docs/api/identity + */ + + async getIdentity(): Promise { + return this.get(`/identity`) + } + + /** + * Get list of supported institutions + * + * https://teller.io/docs/api/identity + */ + + async getInstitutions(): Promise { + return this.get(`/institutions`) + } + + private async getApi(): Promise { + const cert = fs.readFileSync('../../../certs/teller-certificate.pem', 'utf8') + const key = fs.readFileSync('../../../certs/teller-private-key.pem', 'utf8') + + const agent = new https.Agent({ + cert, + key, + }) + + if (!this.api) { + this.api = axios.create({ + httpsAgent: agent, + baseURL: `https://api.teller.io`, + timeout: 30_000, + headers: { + Accept: 'application/json', + }, + }) + } + + return this.api + } + + /** Generic API GET request method */ + private async get( + path: string, + params?: any, + config?: AxiosRequestConfig + ): Promise { + const api = await this.getApi() + return api.get(path, { params, ...config }).then(({ data }) => data) + } + + /** Generic API POST request method */ + private async post( + path: string, + body?: any, + config?: AxiosRequestConfig + ): Promise { + const api = await this.getApi() + return api.post(path, body, config).then(({ data }) => data) + } + + /** Generic API DELETE request method */ + private async delete( + path: string, + params?: any, + config?: AxiosRequestConfig + ): Promise { + const api = await this.getApi() + 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 new file mode 100644 index 00000000..01f0a944 --- /dev/null +++ b/libs/teller-api/src/types/account-balance.ts @@ -0,0 +1,13 @@ +// https://teller.io/docs/api/account/balances + +export type AccountBalance = { + account_id: string + ledger: string + available: string + links: { + self: string + account: string + } +} + +export type GetAccountBalancesResponse = AccountBalance diff --git a/libs/teller-api/src/types/account-details.ts b/libs/teller-api/src/types/account-details.ts new file mode 100644 index 00000000..3dc47ed0 --- /dev/null +++ b/libs/teller-api/src/types/account-details.ts @@ -0,0 +1,17 @@ +// https://teller.io/docs/api/account/details + +export type AccountDetails = { + account_id: string + account_number: string + links: { + account: string + self: string + } + routing_numbers: { + ach?: string + wire?: string + bacs?: string + } +} + +export type GetAccountDetailsResponse = AccountDetails diff --git a/libs/teller-api/src/types/accounts.ts b/libs/teller-api/src/types/accounts.ts new file mode 100644 index 00000000..8faad5c7 --- /dev/null +++ b/libs/teller-api/src/types/accounts.ts @@ -0,0 +1,47 @@ +// https://teller.io/docs/api/accounts + +export type AccountTypes = 'depository' | 'credit' + +export type DepositorySubtypes = + | 'checking' + | 'savings' + | 'money market' + | 'certificate of deposit' + | 'treasury' + | 'sweep' + +export type CreditSubtype = 'credit_card' + +interface BaseAccount { + enrollment_id: string + links: { + balances: string + self: string + transactions: string + } + institution: { + name: string + id: string + } + name: string + currency: string + id: string + last_four: string + status: 'open' | 'closed' +} + +interface DepositoryAccount extends BaseAccount { + type: 'depository' + subtype: DepositorySubtypes +} + +interface CreditAccount extends BaseAccount { + type: 'credit' + subtype: CreditSubtype +} + +export type Account = DepositoryAccount | CreditAccount + +export type GetAccountsResponse = { accounts: Account[] } +export type GetAccountResponse = Account +export type DeleteAccountResponse = void diff --git a/libs/teller-api/src/types/authentication.ts b/libs/teller-api/src/types/authentication.ts new file mode 100644 index 00000000..1f45b91a --- /dev/null +++ b/libs/teller-api/src/types/authentication.ts @@ -0,0 +1,5 @@ +// https://teller.io/docs/api/authentication + +export type AuthenticationResponse = { + token: string +} diff --git a/libs/teller-api/src/types/identity.ts b/libs/teller-api/src/types/identity.ts new file mode 100644 index 00000000..77b36281 --- /dev/null +++ b/libs/teller-api/src/types/identity.ts @@ -0,0 +1,39 @@ +// https://teller.io/docs/api/identity + +import type { Account } from './accounts' + +export type Identity = { + type: 'person' | 'business' + names: Name[] + data: string + addresses: Address[] + phone_numbers: PhoneNumber[] + emails: Email[] +} + +type Name = { + type: 'name' | 'alias' +} + +type Address = { + primary: boolean + street: string + city: string + region: string + postal_code: string + country_code: string +} + +type Email = { + data: string +} + +type PhoneNumber = { + type: 'mobile' | 'home' | 'work' | 'unknown' + data: string +} + +export type GetIdentityResponse = { + account: Account + owners: Identity[] +}[] diff --git a/libs/teller-api/src/types/index.ts b/libs/teller-api/src/types/index.ts new file mode 100644 index 00000000..f3b60309 --- /dev/null +++ b/libs/teller-api/src/types/index.ts @@ -0,0 +1,8 @@ +export * from './accounts' +export * from './account-balance' +export * from './account-details' +export * from './authentication' +export * from './identity' +export * from './institutions' +export * from './transactions' +export * from './webhooks' diff --git a/libs/teller-api/src/types/institutions.ts b/libs/teller-api/src/types/institutions.ts new file mode 100644 index 00000000..6f243375 --- /dev/null +++ b/libs/teller-api/src/types/institutions.ts @@ -0,0 +1,14 @@ +// https://api.teller.io/institutions +// Note: Teller says this is subject to change, specifically the `capabilities` field + +export type Institution = { + id: string + name: string + capabilities: Capability[] +} + +type Capability = 'detail' | 'balance' | 'transaction' | 'identity' + +export type GetInstitutionsResponse = { + institutions: Institution[] +} diff --git a/libs/teller-api/src/types/transactions.ts b/libs/teller-api/src/types/transactions.ts new file mode 100644 index 00000000..9c7dc07b --- /dev/null +++ b/libs/teller-api/src/types/transactions.ts @@ -0,0 +1,59 @@ +// https://teller.io/docs/api/account/transactions + +type DetailCategory = + | 'accommodation' + | 'advertising' + | 'bar' + | 'charity' + | 'clothing' + | 'dining' + | 'education' + | 'electronics' + | 'entertainment' + | 'fuel' + | 'general' + | 'groceries' + | 'health' + | 'home' + | 'income' + | 'insurance' + | 'investment' + | 'loan' + | 'office' + | 'phone' + | 'service' + | 'shopping' + | 'software' + | 'sport' + | 'tax' + | 'transport' + | 'transportation' + | 'utilities' + +type DetailProcessingStatus = 'pending' | 'complete' + +export type Transaction = { + details: { + category?: DetailCategory + processing_status: DetailProcessingStatus + counterparty: { + name?: string + type?: 'organization' | 'person' + } + } + running_balance: string | null + description: string + id: string + date: string + account_id: string + links: { + self: string + account: string + } + amount: string + status: string + type: string +} + +export type GetTransactionsResponse = Transaction[] +export type GetTransactionResponse = Transaction diff --git a/libs/teller-api/src/types/webhooks.ts b/libs/teller-api/src/types/webhooks.ts new file mode 100644 index 00000000..b9a33723 --- /dev/null +++ b/libs/teller-api/src/types/webhooks.ts @@ -0,0 +1,11 @@ +// https://teller.io/docs/api/webhooks + +export type WebhookData = { + id: string + payload: { + enrollment_id: string + reason: string + } + timestamp: string + type: string +} diff --git a/libs/teller-api/tsconfig.json b/libs/teller-api/tsconfig.json new file mode 100644 index 00000000..1e5701a2 --- /dev/null +++ b/libs/teller-api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/teller-api/tsconfig.lib.json b/libs/teller-api/tsconfig.lib.json new file mode 100644 index 00000000..b675f762 --- /dev/null +++ b/libs/teller-api/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "exclude": ["jest.config.ts", "**/*.spec.ts", "s**/*.test.ts"], + "include": ["**/*.ts"] +} diff --git a/libs/teller-api/tsconfig.spec.json b/libs/teller-api/tsconfig.spec.json new file mode 100644 index 00000000..7b0341f5 --- /dev/null +++ b/libs/teller-api/tsconfig.spec.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.test.tsx", + "**/*.spec.tsx", + "**/*.test.js", + "**/*.spec.js", + "**/*.test.jsx", + "**/*.spec.jsx", + "**/*.d.ts" + ] +} diff --git a/package.json b/package.json index 76146f14..4a99fc2b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "license": "MIT", "scripts": { - "dev": "nx run-many --target serve --projects=client,advisor,server,workers --parallel --host 0.0.0.0 --nx-bail=true --maxParallel=100", + "dev": "nx run-many --target serve --projects=client,server,workers --parallel --host 0.0.0.0 --nx-bail=true --maxParallel=100", "dev:services": "COMPOSE_PROFILES=services docker-compose up -d", "dev:services:all": "COMPOSE_PROFILES=services,ngrok,stripe docker-compose up", "dev:workers:test": "nx test workers --skip-nx-cache --runInBand", diff --git a/tsconfig.base.json b/tsconfig.base.json index 09578d91..2d8e8688 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -23,6 +23,7 @@ "@maybe-finance/server/features": ["libs/server/features/src/index.ts"], "@maybe-finance/server/shared": ["libs/server/shared/src/index.ts"], "@maybe-finance/shared": ["libs/shared/src/index.ts"], + "@maybe-finance/teller-api": ["libs/teller-api/src/index.ts"], "@maybe-finance/trpc": ["apps/server/src/app/trpc.ts"] } }, diff --git a/workspace.json b/workspace.json index 00f82b24..012b52a1 100644 --- a/workspace.json +++ b/workspace.json @@ -390,6 +390,30 @@ }, "tags": ["scope:shared"] }, + "teller-api": { + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "root": "libs/teller-api", + "sourceRoot": "libs/teller-api/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/teller-api/**/*.ts"] + } + }, + "test": { + "executor": "@nrwl/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/teller-api/jest.config.ts", + "passWithNoTests": true + } + } + }, + "tags": ["scope:shared"] + }, "workers": { "$schema": "../../node_modules/nx/schemas/project-schema.json", "root": "apps/workers",