diff --git a/.env.example b/.env.example
index fb329150..abd0167b 100644
--- a/.env.example
+++ b/.env.example
@@ -1,27 +1,60 @@
-# Used by `prisma` commands
-NX_DATABASE_URL=postgresql://maybe:maybe@localhost:5433/maybe_local
-NX_DATABASE_SECRET=
-
-# Market data API keys (https://polygon.io)
-NX_POLYGON_API_KEY=
-
-# If using free ngrok account for webhooks
-NGROK_AUTH_TOKEN=
+########################################################################
+# AUTHENTICATION
+########################################################################
# Generate a new secret using openssl rand -base64 32
NEXTAUTH_SECRET=
NEXTAUTH_URL=http://localhost:4200
NX_NEXTAUTH_URL=http://localhost:4200
-NX_PLAID_SECRET=
-NX_FINICITY_APP_KEY=
-NX_FINICITY_PARTNER_SECRET=
+########################################################################
+# WEBHOOKS
+########################################################################
-# Teller API keys (https://teller.io)
+# We use ngrok to expose a local development environment to the internet
+# You can sign up for a free account and get an API key at https://ngrok.com
+NGROK_AUTH_TOKEN=
+
+########################################################################
+# DATABASE
+########################################################################
+
+NX_DATABASE_URL=postgresql://maybe:maybe@localhost:5433/maybe_local
+NX_DATABASE_SECRET=
+
+########################################################################
+# FINANICAL DATA SOURCES
+########################################################################
+
+# Market Data
+# We use Polygon.io for market data. You can sign up for a free account
+# and get an API key for individual use at https://polygon.io
+NX_POLYGON_API_KEY=
+
+# Automated banking data
+# We use Teller.io for automated banking data. You can sign up for a free
+# account and get a free API key at https://teller.io
NX_TELLER_SIGNING_SECRET=
NX_TELLER_APP_ID=
+NX_TELLER_ENV=sandbox
-# Email credentials
+########################################################################
+# EMAIL
+########################################################################
+
+# We use Postmark for transactional emails. You can sign up for a free account
+# and get a free API key at https://postmarkapp.com
NX_POSTMARK_FROM_ADDRESS=account@example.com
NX_POSTMARK_REPLY_TO_ADDRESS=support@example.com
NX_POSTMARK_API_TOKEN=
+
+
+########################################################################
+# DEPRECATING
+# We're in the process of removing code that requires the following
+# environment variables. They will be removed in a future release, but
+# for now, they are still required.
+########################################################################
+NX_PLAID_SECRET=
+NX_FINICITY_APP_KEY=
+NX_FINICITY_PARTNER_SECRET=
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 67e8d549..c133fbea 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -4,6 +4,7 @@ about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
+
---
**Describe the bug**
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature-request-or-improvement.md
similarity index 76%
rename from .github/ISSUE_TEMPLATE/feature_request.md
rename to .github/ISSUE_TEMPLATE/feature-request-or-improvement.md
index 2f28cead..3fb9fcaf 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature-request-or-improvement.md
@@ -1,12 +1,13 @@
---
-name: Feature request
-about: Suggest an idea for this project
+name: Feature request or improvement
+about: Suggest a new feature or improvement
title: ''
labels: ''
assignees: ''
+
---
-**Is your feature request related to a problem? Please describe.**
+**Is your request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
diff --git a/README.md b/README.md
index 396cf5b1..a3c8eca8 100644
--- a/README.md
+++ b/README.md
@@ -57,13 +57,14 @@ Then run the following yarn commands:
```
yarn install
-yarn run dev:services
+yarn run dev:services:all
yarn prisma:migrate:dev
yarn prisma:seed
yarn dev
```
## Contributing
+
To contribute, please see our [contribution guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md).
## High-priority issues
@@ -91,9 +92,9 @@ To pull market data in (for investments), you'll need a Polygon.io API key. You
- [Handling money](https://github.com/maybe-finance/maybe/wiki/Handling-Money)
- [REST API](https://github.com/maybe-finance/maybe/wiki/REST-API)
-
## Repo Activity
-
+
+
## Credits
diff --git a/apps/client/pages/upgrade.tsx b/apps/client/pages/upgrade.tsx
index a0edd511..05ca2feb 100644
--- a/apps/client/pages/upgrade.tsx
+++ b/apps/client/pages/upgrade.tsx
@@ -1,20 +1,23 @@
-import { Button } from '@maybe-finance/design-system'
-import Link from 'next/link'
+import { UpgradeTakeover } from '@maybe-finance/client/features'
+import { useUserApi } from '@maybe-finance/client/shared'
+import { useRouter } from 'next/router'
export default function UpgradePage() {
+ const router = useRouter()
+
+ const { useSubscription } = useUserApi()
+ const subscription = useSubscription()
+
return (
-
-
Signups have been disabled.
-
- Maybe will be shutting down on July 31.{' '}
-
- Details and FAQ
-
-
-
Back home
-
+
+ router.push(
+ !subscription.data || subscription.data?.subscribed
+ ? '/'
+ : '/settings?tab=billing'
+ )
+ }
+ />
)
}
diff --git a/apps/server/src/app/lib/endpoint.ts b/apps/server/src/app/lib/endpoint.ts
index 01856bc3..8e01b00d 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,
@@ -53,7 +54,6 @@ import {
ProjectionCalculator,
StripeWebhookHandler,
} from '@maybe-finance/server/features'
-import { SharedType } from '@maybe-finance/shared'
import prisma from './prisma'
import plaid, { getPlaidWebhookUrl } from './plaid'
import finicity, { getFinicityTxPushUrl, getFinicityWebhookUrl } from './finicity'
@@ -149,8 +149,10 @@ const tellerService = new TellerService(
logger.child({ service: 'TellerService' }),
prisma,
teller,
+ new TellerETL(logger.child({ service: 'TellerETL' }), prisma, teller, cryptoService),
+ cryptoService,
getTellerWebhookUrl(),
- true
+ env.NX_TELLER_ENV === 'sandbox'
)
// account-connection
@@ -158,6 +160,7 @@ const tellerService = new TellerService(
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
plaid: plaidService,
finicity: finicityService,
+ teller: tellerService,
})
const transactionStrategy = new TransactionBalanceSyncStrategy(
diff --git a/apps/server/src/app/lib/postmark.ts b/apps/server/src/app/lib/postmark.ts
index 48eddf1b..ff0e87c6 100644
--- a/apps/server/src/app/lib/postmark.ts
+++ b/apps/server/src/app/lib/postmark.ts
@@ -1,6 +1,6 @@
import { ServerClient } from 'postmark'
import env from '../../env'
-const postmark = new ServerClient(env.NX_POSTMARK_API_TOKEN)
+const postmark = env.NX_POSTMARK_API_TOKEN ? new ServerClient(env.NX_POSTMARK_API_TOKEN) : undefined
export default postmark
diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts
index e69f5f4d..11fd2f76 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(),
@@ -74,7 +75,7 @@ const envSchema = z.object({
NX_POSTMARK_FROM_ADDRESS: z.string().default('account@maybe.co'),
NX_POSTMARK_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'),
- NX_POSTMARK_API_TOKEN: z.string().default('REPLACE_THIS'),
+ NX_POSTMARK_API_TOKEN: z.string().optional(),
})
const env = envSchema.parse(process.env)
diff --git a/apps/workers/src/app/lib/di.ts b/apps/workers/src/app/lib/di.ts
index 55f3341a..987ebedd 100644
--- a/apps/workers/src/app/lib/di.ts
+++ b/apps/workers/src/app/lib/di.ts
@@ -28,6 +28,8 @@ import {
LoanBalanceSyncStrategy,
PlaidETL,
PlaidService,
+ TellerETL,
+ TellerService,
SecurityPricingProcessor,
SecurityPricingService,
TransactionBalanceSyncStrategy,
@@ -55,6 +57,7 @@ import logger from './logger'
import prisma from './prisma'
import plaid from './plaid'
import finicity from './finicity'
+import teller from './teller'
import postmark from './postmark'
import stripe from './stripe'
import env from '../../env'
@@ -124,11 +127,22 @@ const finicityService = new FinicityService(
env.NX_FINICITY_ENV === 'sandbox'
)
+const tellerService = new TellerService(
+ logger.child({ service: 'TellerService' }),
+ prisma,
+ teller,
+ new TellerETL(logger.child({ service: 'TellerETL' }), prisma, teller, cryptoService),
+ cryptoService,
+ '',
+ env.NX_TELLER_ENV === 'sandbox'
+)
+
// account-connection
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
plaid: plaidService,
finicity: finicityService,
+ teller: tellerService,
})
const transactionStrategy = new TransactionBalanceSyncStrategy(
diff --git a/apps/workers/src/app/lib/postmark.ts b/apps/workers/src/app/lib/postmark.ts
index 48eddf1b..ff0e87c6 100644
--- a/apps/workers/src/app/lib/postmark.ts
+++ b/apps/workers/src/app/lib/postmark.ts
@@ -1,6 +1,6 @@
import { ServerClient } from 'postmark'
import env from '../../env'
-const postmark = new ServerClient(env.NX_POSTMARK_API_TOKEN)
+const postmark = env.NX_POSTMARK_API_TOKEN ? new ServerClient(env.NX_POSTMARK_API_TOKEN) : undefined
export default postmark
diff --git a/apps/workers/src/app/lib/teller.ts b/apps/workers/src/app/lib/teller.ts
new file mode 100644
index 00000000..00a7beb1
--- /dev/null
+++ b/apps/workers/src/app/lib/teller.ts
@@ -0,0 +1,5 @@
+import { TellerApi } from '@maybe-finance/teller-api'
+
+const teller = new TellerApi()
+
+export default teller
diff --git a/apps/workers/src/env.ts b/apps/workers/src/env.ts
index c41d9692..f58963bf 100644
--- a/apps/workers/src/env.ts
+++ b/apps/workers/src/env.ts
@@ -17,6 +17,7 @@ const envSchema = z.object({
NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'),
NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'),
+ NX_TELLER_ENV: z.string().default('sandbox'),
NX_SENTRY_DSN: z.string().optional(),
NX_SENTRY_ENV: z.string().optional(),
@@ -29,7 +30,7 @@ const envSchema = z.object({
NX_POSTMARK_FROM_ADDRESS: z.string().default('account@maybe.co'),
NX_POSTMARK_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'),
- NX_POSTMARK_API_TOKEN: z.string().default('REPLACE_THIS'),
+ NX_POSTMARK_API_TOKEN: z.string().optional(),
NX_STRIPE_SECRET_KEY: z.string().default('sk_test_REPLACE_THIS'),
NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'),
diff --git a/libs/client/features/src/layout/DesktopLayout.tsx b/libs/client/features/src/layout/DesktopLayout.tsx
index e61fbdac..21023d6b 100644
--- a/libs/client/features/src/layout/DesktopLayout.tsx
+++ b/libs/client/features/src/layout/DesktopLayout.tsx
@@ -23,6 +23,7 @@ import {
} from 'react-icons/ri'
import { Button, Tooltip } from '@maybe-finance/design-system'
import { MenuPopover } from './MenuPopover'
+import { UpgradePrompt } from '../user-billing'
import { SidebarOnboarding } from '../onboarding'
import { useSession } from 'next-auth/react'
@@ -227,7 +228,7 @@ export function DesktopLayout({ children, sidebar }: DesktopLayoutProps) {
className="p-3 text-base bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-600"
onClick={() => setOnboardingExpanded(true)}
>
-
+
Getting started
{onboarding.data.isComplete && (
Hide
@@ -337,6 +338,8 @@ function DefaultContent({
{onboarding && onboarding}
+
+
{name ?? ''}
diff --git a/libs/client/features/src/layout/MobileLayout.tsx b/libs/client/features/src/layout/MobileLayout.tsx
index 206df618..585af1dd 100644
--- a/libs/client/features/src/layout/MobileLayout.tsx
+++ b/libs/client/features/src/layout/MobileLayout.tsx
@@ -12,6 +12,7 @@ import { Button } from '@maybe-finance/design-system'
import { MenuPopover } from './MenuPopover'
import Link from 'next/link'
import { useRouter } from 'next/router'
+import { UpgradePrompt } from '../user-billing'
import { ProfileCircle } from '@maybe-finance/client/shared'
import { usePopoutContext, LayoutContextProvider } from '@maybe-finance/client/shared'
import classNames from 'classnames'
@@ -90,10 +91,10 @@ function NavItem({
className="absolute inset-0"
transition={{ duration: 0.3 }}
>
-
+
)}
-
+
{label}
@@ -138,7 +139,7 @@ export function MobileLayout({ children, sidebar }: MobileLayoutProps) {
>
-
+
setCollapsed(true)}>
@@ -156,7 +157,7 @@ export function MobileLayout({ children, sidebar }: MobileLayoutProps) {
-
diff --git a/libs/client/features/src/onboarding/steps/Profile.tsx b/libs/client/features/src/onboarding/steps/Profile.tsx
index 7201ead9..1bb5d704 100644
--- a/libs/client/features/src/onboarding/steps/Profile.tsx
+++ b/libs/client/features/src/onboarding/steps/Profile.tsx
@@ -144,6 +144,7 @@ function ProfileForm({ title, onSubmit, defaultValues }: ProfileViewProps) {
diff --git a/libs/client/features/src/user-billing/BillingPreferences.tsx b/libs/client/features/src/user-billing/BillingPreferences.tsx
index 1ab89ff9..022a03d8 100644
--- a/libs/client/features/src/user-billing/BillingPreferences.tsx
+++ b/libs/client/features/src/user-billing/BillingPreferences.tsx
@@ -4,7 +4,6 @@ import { useState } from 'react'
import { RiExternalLinkLine } from 'react-icons/ri'
import { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'
import { UpgradeTakeover } from '.'
-import Link from 'next/link'
export function BillingPreferences() {
const { useSubscription, useCreateCustomerPortalSession } = useUserApi()
@@ -20,16 +19,16 @@ export function BillingPreferences() {
return (
<>
-
+
Billing
{isLoading || !data ? (
-
+
) : (
-
+
-
+
{data.trialing ? (
Your free trial will end on{' '}
@@ -63,37 +62,21 @@ export function BillingPreferences() {
createCustomerPortalSession.isSuccess ? (
) : (
-
+
)}
) : (
- setTakeoverOpen(true)}
- >
- Subscriptions disabled
+ setTakeoverOpen(true)}>
+ Subscribe
)}
-
+
You’ll be redirected to Stripe to manage billing.
)}
-
-
-
- Maybe will be shutting down on July 31.{' '}
-
- Details and FAQ
-
-
-
setTakeoverOpen(false)} />
>
diff --git a/libs/server/features/src/email/email.service.ts b/libs/server/features/src/email/email.service.ts
index 6c908985..b8b1d3b7 100644
--- a/libs/server/features/src/email/email.service.ts
+++ b/libs/server/features/src/email/email.service.ts
@@ -22,7 +22,7 @@ export interface IEmailService {
export class EmailService implements IEmailService {
constructor(
private readonly logger: Logger,
- private readonly postmark: PostmarkServerClient,
+ private readonly postmark: PostmarkServerClient | undefined,
private readonly defaultAddresses: { from: string; replyTo?: string }
) {}
@@ -85,6 +85,11 @@ export class EmailService implements IEmailService {
message.TemplateModel
)
+ if (!this.postmark) {
+ this.logger.info('Postmark API key not provided, skipping email send')
+ return undefined as unknown as MessageSendingResponse
+ }
+
return await this.postmark.sendEmailWithTemplate(message)
}
@@ -94,6 +99,11 @@ export class EmailService implements IEmailService {
{ text: message.TextBody, html: message.HtmlBody }
)
+ if (!this.postmark) {
+ this.logger.info('Postmark API key not provided, skipping email send')
+ return undefined as unknown as MessageSendingResponse
+ }
+
return await this.postmark.sendEmail(message)
}
@@ -108,9 +118,13 @@ export class EmailService implements IEmailService {
return (
await Promise.all(
- chunk(messages, 500).map((chunk) =>
- this.postmark.sendEmailBatchWithTemplates(chunk)
- )
+ chunk(messages, 500).map((chunk) => {
+ if (!this.postmark) {
+ this.logger.info('Postmark API key not provided, skipping email send')
+ return [] as MessageSendingResponse[]
+ }
+ return this.postmark.sendEmailBatchWithTemplates(chunk)
+ })
)
).flat()
}
@@ -124,7 +138,13 @@ export class EmailService implements IEmailService {
return (
await Promise.all(
- chunk(messages, 500).map((chunk) => this.postmark.sendEmailBatch(chunk))
+ chunk(messages, 500).map((chunk) => {
+ if (!this.postmark) {
+ this.logger.info('Postmark API key not provided, skipping email send')
+ return [] as MessageSendingResponse[]
+ }
+ return this.postmark.sendEmailBatch(chunk)
+ })
)
).flat()
}
diff --git a/libs/server/features/src/providers/teller/index.ts b/libs/server/features/src/providers/teller/index.ts
index 3f35c0cf..00a28837 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 00000000..241934bb
--- /dev/null
+++ b/libs/server/features/src/providers/teller/teller.etl.ts
@@ -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
+}
+
+export type TellerData = {
+ accounts: TellerTypes.AccountWithBalances[]
+ transactions: TellerTypes.Transaction[]
+ transactionsDateRange: SharedType.DateRange
+}
+
+type Connection = Pick<
+ AccountConnection,
+ 'id' | 'userId' | 'tellerInstitutionId' | 'tellerAccessToken'
+>
+
+export class TellerETL implements IETL {
+ 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 {
+ 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 {
+ 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(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) {
+ 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
+ ) {
+ 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 de393b22..e884639f 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(
+ `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,
+ }
+ })
+ }
}
diff --git a/libs/server/shared/src/services/queue.service.ts b/libs/server/shared/src/services/queue.service.ts
index 2e6c2160..319fef92 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 3366b3fb..65cefc6a 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 bad4dc18..4ba6606d 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 00000000..a8e8e3ee
--- /dev/null
+++ b/libs/server/shared/src/utils/teller-utils.ts
@@ -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,
+ 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
+ }
+}
diff --git a/libs/shared/src/types/general-types.ts b/libs/shared/src/types/general-types.ts
index 97117f01..30a28824 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 46670971..ca3a180a 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 01f0a944..d2e21193 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 3dc47ed0..fb6e8405 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 8faad5c7..5df29953 100644
--- a/libs/teller-api/src/types/accounts.ts
+++ b/libs/teller-api/src/types/accounts.ts
@@ -1,7 +1,14 @@
// https://teller.io/docs/api/accounts
+import type { AccountBalance } from './account-balance'
+import type { AuthenticatedRequest } from './authentication'
export type AccountTypes = 'depository' | 'credit'
+export enum AccountType {
+ 'depository',
+ 'credit',
+}
+
export type DepositorySubtypes =
| 'checking'
| 'savings'
@@ -42,6 +49,16 @@ interface CreditAccount extends BaseAccount {
export type Account = DepositoryAccount | CreditAccount
-export type GetAccountsResponse = { accounts: Account[] }
+export type AccountWithBalances = Account & {
+ balances: AccountBalance
+}
+
+export type GetAccountsResponse = Account[]
export type GetAccountResponse = Account
export type DeleteAccountResponse = void
+
+export interface GetAccountRequest extends AuthenticatedRequest {
+ accountId: string
+}
+
+export type DeleteAccountRequest = GetAccountRequest
diff --git a/libs/teller-api/src/types/authentication.ts b/libs/teller-api/src/types/authentication.ts
index 1f45b91a..b2826a81 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 00000000..33b702c2
--- /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 f3b60309..ca90d347 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 9c7dc07b..1d482aa8 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/package.json b/package.json
index 66f5ff0f..74aa82e4 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"scripts": {
"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:services:all": "COMPOSE_PROFILES=services,ngrok docker-compose up -d",
"dev:workers:test": "nx test workers --skip-nx-cache --runInBand",
"dev:server:test": "nx test server --skip-nx-cache --runInBand",
"dev:test:unit": "yarn dev:ci:test --testPathPattern='^(?!.*integration).*$' --verbose --skip-nx-cache",
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 00000000..e05cf085
--- /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/migrations/20240116023100_add_additional_teller_fields/migration.sql b/prisma/migrations/20240116023100_add_additional_teller_fields/migration.sql
new file mode 100644
index 00000000..1627c0f0
--- /dev/null
+++ b/prisma/migrations/20240116023100_add_additional_teller_fields/migration.sql
@@ -0,0 +1,5 @@
+-- AlterEnum
+ALTER TYPE "AccountProvider" ADD VALUE 'teller';
+
+-- AlterTable
+ALTER TABLE "account" ADD COLUMN "teller_subtype" TEXT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 587a274c..007cad61 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])
@@ -102,6 +109,7 @@ enum AccountProvider {
user
plaid
finicity
+ teller
}
enum AccountBalanceStrategy {
@@ -152,6 +160,11 @@ model Account {
finicityType String? @map("finicity_type")
finicityDetail Json? @map("finicity_detail") @db.JsonB
+ // teller data
+ tellerAccountId String? @map("teller_account_id")
+ tellerType String? @map("teller_type")
+ tellerSubtype String? @map("teller_subtype")
+
// manual account data
vehicleMeta Json? @map("vehicle_meta") @db.JsonB
propertyMeta Json? @map("property_meta") @db.JsonB
@@ -172,6 +185,7 @@ model Account {
@@unique([accountConnectionId, plaidAccountId])
@@unique([accountConnectionId, finicityAccountId])
+ @@unique([accountConnectionId, tellerAccountId])
@@index([accountConnectionId])
@@index([userId])
@@map("account")
@@ -346,6 +360,11 @@ model Transaction {
finicityType String? @map("finicity_type")
finicityCategorization Json? @map("finicity_categorization") @db.JsonB
+ // teller data
+ tellerTransactionId String? @unique @map("teller_transaction_id")
+ tellerType String? @map("teller_type")
+ tellerCategory String? @map("teller_category")
+
@@index([accountId, date])
@@index([amount])
@@map("transaction")
@@ -430,6 +449,9 @@ model User {
// plaid data
plaidLinkToken String? @map("plaid_link_token") // temporary token stored to maintain state across browsers
+ // teller data
+ tellerUserId String? @unique @map("teller_user_id")
+
// Onboarding / usage goals
household Household?
state String?