1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 07:25:19 +02:00
This commit is contained in:
Brian Skinner 2024-01-16 07:40:59 -08:00
commit be27bddb4c
37 changed files with 778 additions and 106 deletions

View file

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

View file

@ -4,6 +4,7 @@ about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**

View file

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

View file

@ -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
![Repo Activity](https://repobeats.axiom.co/api/embed/7866c9790deba0baf63ca1688b209130b306ea4e.svg "Repobeats analytics image")
![Repo Activity](https://repobeats.axiom.co/api/embed/7866c9790deba0baf63ca1688b209130b306ea4e.svg 'Repobeats analytics image')
## Credits

View file

@ -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 (
<div className="h-screen flex flex-col justify-center items-center space-y-4">
<h3>Signups have been disabled.</h3>
<p>
Maybe will be shutting down on July 31.{' '}
<Link
className="text-cyan-500 underline hover:text-cyan-400"
href="https://maybefinance.notion.site/To-Investors-Customers-The-Future-of-Maybe-6758bfc0e46f4ec68bf4a7a8f619199f"
>
Details and FAQ
</Link>
</p>
<Button href="/">Back home</Button>
</div>
<UpgradeTakeover
open
onClose={() =>
router.push(
!subscription.data || subscription.data?.subscribed
? '/'
: '/settings?tab=billing'
)
}
/>
)
}

View file

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

View file

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

View file

@ -43,6 +43,7 @@ const envSchema = z.object({
NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'),
NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'),
NX_TELLER_ENV: z.string().default('sandbox'),
NX_SENTRY_DSN: z.string().optional(),
NX_SENTRY_ENV: z.string().optional(),
@ -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)

View file

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

View file

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

View file

@ -0,0 +1,5 @@
import { TellerApi } from '@maybe-finance/teller-api'
const teller = new TellerApi()
export default teller

View file

@ -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'),

View file

@ -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)}
>
<div className="flex items-center justify-between text-sm mb-1">
<div className="flex items-center justify-between mb-1 text-sm">
<p className="text-gray-50">Getting started</p>
{onboarding.data.isComplete && (
<button
@ -235,7 +236,7 @@ export function DesktopLayout({ children, sidebar }: DesktopLayoutProps) {
e.stopPropagation()
hideOnboardingWidgetForever()
}}
className="bg-gray-600 hover:bg-gray-500 rounded p-1"
className="p-1 bg-gray-600 rounded hover:bg-gray-500"
>
Hide
</button>
@ -337,6 +338,8 @@ function DefaultContent({
{onboarding && onboarding}
<UpgradePrompt />
<div className="flex items-center justify-between">
<div className="text-base">
<p data-testid="user-name">{name ?? ''}</p>

View file

@ -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 }}
>
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-5 h-1 bg-white rounded-t-lg"></div>
<div className="absolute bottom-0 w-5 h-1 -translate-x-1/2 bg-white rounded-t-lg left-1/2"></div>
</motion.div>
)}
<Icon className="shrink-0 w-6 h-6" />
<Icon className="w-6 h-6 shrink-0" />
<span className="shrink-0 mt-1.5 text-sm font-medium text-center">{label}</span>
</Link>
</div>
@ -138,7 +139,7 @@ export function MobileLayout({ children, sidebar }: MobileLayoutProps) {
>
<div>
<nav>
<div className="flex items-center justify-between px-4 h-20">
<div className="flex items-center justify-between h-20 px-4">
<div className="w-10">
<Button variant="icon" onClick={() => setCollapsed(true)}>
<RiCloseLine className="w-6 h-6" />
@ -156,7 +157,7 @@ export function MobileLayout({ children, sidebar }: MobileLayoutProps) {
<ProfileCircle className="!w-10 !h-10" />
</Link>
</div>
<ul className="flex items-end justify-center xs:gap-2 border-b border-gray-700">
<ul className="flex items-end justify-center border-b border-gray-700 xs:gap-2">
<NavItem label="Net worth" href="/" icon={RiPieChart2Line} />
<NavItem label="Accounts" href="/accounts" icon={RiFolderOpenLine} />
<NavItem
@ -172,7 +173,9 @@ export function MobileLayout({ children, sidebar }: MobileLayoutProps) {
{sidebar}
</section>
<div className="shrink-0 pt-6"></div>
<div className="pt-6 shrink-0">
<UpgradePrompt />
</div>
</div>
</div>
</motion.aside>

View file

@ -144,6 +144,7 @@ function ProfileForm({ title, onSubmit, defaultValues }: ProfileViewProps) {
<DatePicker
popperPlacement="bottom"
className="mt-2"
minCalendarDate={DateTime.now().minus({ years: 100 }).toISODate()}
error={error?.message}
{...field}
/>

View file

@ -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 (
<>
<div className="mt-6 max-w-lg text-base">
<div className="max-w-lg mt-6 text-base">
<h4 className="mb-2 text-lg uppercase">Billing</h4>
{isLoading || !data ? (
<div className="flex items-center justify-center w-lg max-w-full py-8">
<div className="flex items-center justify-center max-w-full py-8 w-lg">
<LoadingSpinner />
</div>
) : (
<div className="bg-gray-800 rounded-lg overflow-hidden">
<div className="overflow-hidden bg-gray-800 rounded-lg">
<div className="flex items-center p-4">
<div className="grow text-gray-50 pr-4">
<div className="pr-4 grow text-gray-50">
{data.trialing ? (
<p>
Your free trial will end on{' '}
@ -63,37 +62,21 @@ export function BillingPreferences() {
createCustomerPortalSession.isSuccess ? (
<LoadingIcon className="text-gray-100 ml-2.5 mr-1 w-3.5 h-3.5 animate-spin" />
) : (
<RiExternalLinkLine className="ml-2 w-5 h-5" />
<RiExternalLinkLine className="w-5 h-5 ml-2" />
)}
</Button>
) : (
<Button
variant="primary"
disabled
onClick={() => setTakeoverOpen(true)}
>
Subscriptions disabled
<Button variant="primary" onClick={() => setTakeoverOpen(true)}>
Subscribe
</Button>
)}
</div>
</div>
<div className="p-3 text-sm bg-gray-700 text-gray-100">
<div className="p-3 text-sm text-gray-100 bg-gray-700">
You&rsquo;ll be redirected to Stripe to manage billing.
</div>
</div>
)}
<div className="mt-8 bg-cyan text-white p-3 rounded">
<p className="">
Maybe will be shutting down on July 31.{' '}
<Link
className="text-white font-bold underline"
href="https://maybefinance.notion.site/To-Investors-Customers-The-Future-of-Maybe-6758bfc0e46f4ec68bf4a7a8f619199f"
>
Details and FAQ
</Link>
</p>
</div>
</div>
<UpgradeTakeover open={takeoverOpen} onClose={() => setTakeoverOpen(false)} />
</>

View file

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

View file

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

View file

@ -0,0 +1,271 @@
import type { AccountConnection, PrismaClient } from '@prisma/client'
import type { Logger } from 'winston'
import { SharedUtil, AccountUtil, type SharedType } from '@maybe-finance/shared'
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
import { DbUtil, TellerUtil, type IETL, type ICryptoService } from '@maybe-finance/server/shared'
import { Prisma } from '@prisma/client'
import _ from 'lodash'
import { DateTime } from 'luxon'
export type TellerRawData = {
accounts: TellerTypes.Account[]
transactions: TellerTypes.Transaction[]
transactionsDateRange: SharedType.DateRange<DateTime>
}
export type TellerData = {
accounts: TellerTypes.AccountWithBalances[]
transactions: TellerTypes.Transaction[]
transactionsDateRange: SharedType.DateRange<DateTime>
}
type Connection = Pick<
AccountConnection,
'id' | 'userId' | 'tellerInstitutionId' | 'tellerAccessToken'
>
export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
public constructor(
private readonly logger: Logger,
private readonly prisma: PrismaClient,
private readonly teller: Pick<
TellerApi,
'getAccounts' | 'getTransactions' | 'getAccountBalances'
>,
private readonly crypto: ICryptoService
) {}
async extract(connection: Connection): Promise<TellerRawData> {
if (!connection.tellerInstitutionId) {
throw new Error(`connection ${connection.id} is missing tellerInstitutionId`)
}
if (!connection.tellerAccessToken) {
throw new Error(`connection ${connection.id} is missing tellerAccessToken`)
}
const accessToken = this.crypto.decrypt(connection.tellerAccessToken)
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: connection.userId },
select: {
id: true,
tellerUserId: true,
},
})
if (!user.tellerUserId) {
throw new Error(`user ${user.id} is missing tellerUserId`)
}
// TODO: Check if Teller supports date ranges for transactions
const transactionsDateRange = {
start: DateTime.now().minus(TellerUtil.TELLER_WINDOW_MAX),
end: DateTime.now(),
}
const accounts = await this._extractAccounts(accessToken)
const transactions = await this._extractTransactions(
accessToken,
accounts.map((a) => a.id)
)
this.logger.info(
`Extracted Teller data for customer ${user.tellerUserId} accounts=${accounts.length} transactions=${transactions.length}`,
{ connection: connection.id, transactionsDateRange }
)
return {
accounts,
transactions,
transactionsDateRange,
}
}
async transform(_connection: Connection, data: TellerData): Promise<TellerData> {
return {
...data,
}
}
async load(connection: Connection, data: TellerData): Promise<void> {
await this.prisma.$transaction([
...this._loadAccounts(connection, data),
...this._loadTransactions(connection, data),
])
this.logger.info(`Loaded Teller data for connection ${connection.id}`, {
connection: connection.id,
})
}
private async _extractAccounts(accessToken: string) {
const accounts = await this.teller.getAccounts({ accessToken })
const accountsWithBalances = await Promise.all(
accounts.map(async (a) => {
const balance = await this.teller.getAccountBalances({
accountId: a.id,
accessToken,
})
return { ...a, balance }
})
)
return accountsWithBalances
}
private _loadAccounts(connection: Connection, { accounts }: Pick<TellerData, 'accounts'>) {
return [
// upsert accounts
...accounts.map((tellerAccount) => {
const type = TellerUtil.getType(tellerAccount.type)
const classification = AccountUtil.getClassification(type)
return this.prisma.account.upsert({
where: {
accountConnectionId_tellerAccountId: {
accountConnectionId: connection.id,
tellerAccountId: tellerAccount.id,
},
},
create: {
type: TellerUtil.getType(tellerAccount.type),
provider: 'teller',
categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type),
subcategoryProvider: tellerAccount.subtype ?? 'other',
accountConnectionId: connection.id,
tellerAccountId: tellerAccount.id,
name: tellerAccount.name,
tellerType: tellerAccount.type,
tellerSubtype: tellerAccount.subtype,
mask: tellerAccount.last_four,
...TellerUtil.getAccountBalanceData(tellerAccount, classification),
},
update: {
type: TellerUtil.getType(tellerAccount.type),
categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type),
subcategoryProvider: tellerAccount.subtype ?? 'other',
tellerType: tellerAccount.type,
tellerSubtype: tellerAccount.subtype,
..._.omit(TellerUtil.getAccountBalanceData(tellerAccount, classification), [
'currentBalanceStrategy',
'availableBalanceStrategy',
]),
},
})
}),
// any accounts that are no longer in Teller should be marked inactive
this.prisma.account.updateMany({
where: {
accountConnectionId: connection.id,
AND: [
{ tellerAccountId: { not: null } },
{ tellerAccountId: { notIn: accounts.map((a) => a.id) } },
],
},
data: {
isActive: false,
},
}),
]
}
private async _extractTransactions(accessToken: string, accountIds: string[]) {
const accountTransactions = await Promise.all(
accountIds.map((accountId) =>
SharedUtil.paginate({
pageSize: 1000, // TODO: Check with Teller on max page size
fetchData: async () => {
const transactions = await SharedUtil.withRetry(
() =>
this.teller.getTransactions({
accountId,
accessToken: accessToken,
}),
{
maxRetries: 3,
}
)
return transactions
},
})
)
)
return accountTransactions.flat()
}
private _loadTransactions(
connection: Connection,
{
transactions,
transactionsDateRange,
}: Pick<TellerData, 'transactions' | 'transactionsDateRange'>
) {
if (!transactions.length) return []
const txnUpsertQueries = _.chunk(transactions, 1_000).map((chunk) => {
return this.prisma.$executeRaw`
INSERT INTO transaction (account_id, teller_transaction_id, date, name, amount, pending, currency_code, merchant_name, teller_type, teller_category)
VALUES
${Prisma.join(
chunk.map((tellerTransaction) => {
const {
id,
account_id,
description,
amount,
status,
type,
details,
date,
} = tellerTransaction
return Prisma.sql`(
(SELECT id FROM account WHERE account_connection_id = ${
connection.id
} AND teller_account_id = ${account_id.toString()}),
${id},
${date}::date,
${[description].filter(Boolean).join(' ')},
${DbUtil.toDecimal(-amount)},
${status === 'pending'},
${'USD'},
${details.counterparty.name ?? ''},
${type},
${details.category ?? ''},
)`
})
)}
ON CONFLICT (teller_transaction_id) DO UPDATE
SET
name = EXCLUDED.name,
amount = EXCLUDED.amount,
pending = EXCLUDED.pending,
merchant_name = EXCLUDED.merchant_name,
teller_type = EXCLUDED.teller_type,
teller_category = EXCLUDED.teller_category;
`
})
return [
// upsert transactions
...txnUpsertQueries,
// delete teller-specific transactions that are no longer in teller
this.prisma.transaction.deleteMany({
where: {
account: {
accountConnectionId: connection.id,
},
AND: [
{ tellerTransactionId: { not: null } },
{ tellerTransactionId: { notIn: transactions.map((t) => `${t.id}`) } },
],
date: {
gte: transactionsDateRange.start.startOf('day').toJSDate(),
lte: transactionsDateRange.end.endOf('day').toJSDate(),
},
},
}),
]
}
}

View file

@ -1,5 +1,14 @@
import type { Logger } from 'winston'
import type { AccountConnection, PrismaClient, User } from '@prisma/client'
import type { IInstitutionProvider } from '../../institution'
import type {
AccountConnectionSyncEvent,
IAccountConnectionProvider,
} from '../../account-connection'
import { SharedUtil } from '@maybe-finance/shared'
import type { SyncConnectionOptions, CryptoService, IETL } from '@maybe-finance/server/shared'
import _ from 'lodash'
import { ErrorUtil, etl } from '@maybe-finance/server/shared'
import type { TellerApi } from '@maybe-finance/teller-api'
export interface ITellerConnect {
@ -11,12 +20,107 @@ export interface ITellerConnect {
): Promise<{ link: string }>
}
export class TellerService {
export class TellerService implements IAccountConnectionProvider, IInstitutionProvider {
constructor(
private readonly logger: Logger,
private readonly prisma: PrismaClient,
private readonly teller: TellerApi,
private readonly etl: IETL<AccountConnection>,
private readonly crypto: CryptoService,
private readonly webhookUrl: string | Promise<string>,
private readonly testMode: boolean
) {}
async sync(connection: AccountConnection, options?: SyncConnectionOptions) {
if (options && options.type !== 'teller') throw new Error('invalid sync options')
await etl(this.etl, connection)
}
async onSyncEvent(connection: AccountConnection, event: AccountConnectionSyncEvent) {
switch (event.type) {
case 'success': {
await this.prisma.accountConnection.update({
where: { id: connection.id },
data: {
status: 'OK',
},
})
break
}
case 'error': {
const { error } = event
await this.prisma.accountConnection.update({
where: { id: connection.id },
data: {
status: 'ERROR',
tellerError: ErrorUtil.isTellerError(error)
? (error.response.data as any)
: undefined,
},
})
break
}
}
}
async delete(connection: AccountConnection) {
// purge teller data
if (connection.tellerAccessToken && connection.tellerAccountId) {
await this.teller.deleteAccount({
accessToken: this.crypto.decrypt(connection.tellerAccessToken),
accountId: connection.tellerAccountId,
})
this.logger.info(`Item ${connection.tellerAccountId} removed`)
}
}
async getInstitutions() {
const tellerInstitutions = await SharedUtil.paginate({
pageSize: 500,
delay:
process.env.NODE_ENV !== 'production'
? {
onDelay: (message: string) => this.logger.debug(message),
milliseconds: 7_000, // Sandbox rate limited at 10 calls / minute
}
: undefined,
fetchData: (offset, count) =>
SharedUtil.withRetry(
() =>
this.teller.getInstitutions().then((data) => {
this.logger.debug(
`paginated teller fetch inst=${data.institutions.length} (total=${data.institutions.length} offset=${offset} count=${count})`
)
return data.institutions
}),
{
maxRetries: 3,
onError: (error, attempt) => {
this.logger.error(
`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,
}
})
}
}

View file

@ -41,6 +41,7 @@ export type SyncConnectionOptions =
products?: Array<'transactions' | 'investment-transactions' | 'holdings' | 'liabilities'>
}
| { type: 'finicity'; initialSync?: boolean }
| { type: 'teller'; initialSync?: boolean }
export type SyncConnectionQueueJobData = {
accountConnectionId: AccountConnection['id']

View file

@ -32,6 +32,15 @@ export function isPlaidError(err: unknown): err is SharedType.AxiosPlaidError {
return 'error_type' in data && 'error_code' in data && 'error_message' in data
}
export function isTellerError(err: unknown): err is SharedType.AxiosTellerError {
if (!err) return false
if (!axios.isAxiosError(err)) return false
if (typeof err.response?.data !== 'object') return false
const { data } = err.response
return 'code' in data.error && 'message' in data.error
}
export function parseError(error: unknown): SharedType.ParsedError {
if (isPlaidError(error)) {
return parsePlaidError(error)

View file

@ -2,6 +2,7 @@ export * as AuthUtil from './auth-utils'
export * as DbUtil from './db-utils'
export * as FinicityUtil from './finicity-utils'
export * as PlaidUtil from './plaid-utils'
export * as TellerUtil from './teller-utils'
export * as ErrorUtil from './error-utils'
// All "generic" server utils grouped here

View file

@ -0,0 +1,63 @@
import {
Prisma,
AccountCategory,
AccountType,
type AccountClassification,
type Account,
} from '@prisma/client'
import type { TellerTypes } from '@maybe-finance/teller-api'
import { Duration } from 'luxon'
/**
* Update this with the max window that Teller supports
*/
export const TELLER_WINDOW_MAX = Duration.fromObject({ years: 1 })
export function getAccountBalanceData(
{ balances, currency }: Pick<TellerTypes.AccountWithBalances, 'balances' | 'currency'>,
classification: AccountClassification
): Pick<
Account,
| 'currentBalanceProvider'
| 'currentBalanceStrategy'
| 'availableBalanceProvider'
| 'availableBalanceStrategy'
| 'currencyCode'
> {
// Flip balance values to positive for liabilities
const sign = classification === 'liability' ? -1 : 1
return {
currentBalanceProvider: new Prisma.Decimal(
balances.ledger ? sign * Number(balances.ledger) : 0
),
currentBalanceStrategy: 'current',
availableBalanceProvider: new Prisma.Decimal(
balances.available ? sign * Number(balances.available) : 0
),
availableBalanceStrategy: 'available',
currencyCode: currency,
}
}
export function getType(type: TellerTypes.AccountTypes): AccountType {
switch (type) {
case 'depository':
return AccountType.DEPOSITORY
case 'credit':
return AccountType.CREDIT
default:
return AccountType.OTHER_ASSET
}
}
export function tellerTypesToCategory(tellerType: TellerTypes.AccountTypes): AccountCategory {
switch (tellerType) {
case 'depository':
return AccountCategory.cash
case 'credit':
return AccountCategory.credit
default:
return AccountCategory.other
}
}

View file

@ -1,6 +1,7 @@
import type { Prisma } from '@prisma/client'
import type { PlaidError } from 'plaid'
import type { AxiosError } from 'axios'
import type { TellerTypes } from '@maybe-finance/teller-api'
import type { Contexts, Primitive } from '@sentry/types'
import type DecimalJS from 'decimal.js'
import type { O } from 'ts-toolbelt'
@ -79,6 +80,11 @@ export type ParsedError = {
export type AxiosPlaidError = O.Required<AxiosError<PlaidError>, 'response' | 'config'>
export type AxiosTellerError = O.Required<
AxiosError<TellerTypes.TellerError>,
'response' | 'config'
>
export type StatusPageResponse = {
page?: {
id?: string

View file

@ -9,6 +9,13 @@ import type {
DeleteAccountResponse,
GetAccountDetailsResponse,
GetInstitutionsResponse,
AuthenticatedRequest,
GetAccountRequest,
DeleteAccountRequest,
GetAccountDetailsRequest,
GetAccountBalancesRequest,
GetTransactionsRequest,
GetTransactionRequest,
} from './types'
import axios from 'axios'
import * as fs from 'fs'
@ -26,8 +33,8 @@ export class TellerApi {
* https://teller.io/docs/api/accounts
*/
async getAccounts(): Promise<GetAccountsResponse> {
return this.get<GetAccountsResponse>(`/accounts`)
async getAccounts({ accessToken }: AuthenticatedRequest): Promise<GetAccountsResponse> {
return this.get<GetAccountsResponse>(`/accounts`, accessToken)
}
/**
@ -36,8 +43,8 @@ export class TellerApi {
* https://teller.io/docs/api/accounts
*/
async getAccount(accountId: string): Promise<GetAccountResponse> {
return this.get<GetAccountResponse>(`/accounts/${accountId}`)
async getAccount({ accountId, accessToken }: GetAccountRequest): Promise<GetAccountResponse> {
return this.get<GetAccountResponse>(`/accounts/${accountId}`, accessToken)
}
/**
@ -46,8 +53,11 @@ export class TellerApi {
* https://teller.io/docs/api/accounts
*/
async deleteAccount(accountId: string): Promise<DeleteAccountResponse> {
return this.delete<DeleteAccountResponse>(`/accounts/${accountId}`)
async deleteAccount({
accountId,
accessToken,
}: DeleteAccountRequest): Promise<DeleteAccountResponse> {
return this.delete<DeleteAccountResponse>(`/accounts/${accountId}`, accessToken)
}
/**
@ -56,8 +66,11 @@ export class TellerApi {
* https://teller.io/docs/api/account/details
*/
async getAccountDetails(accountId: string): Promise<GetAccountDetailsResponse> {
return this.get<GetAccountDetailsResponse>(`/accounts/${accountId}/details`)
async getAccountDetails({
accountId,
accessToken,
}: GetAccountDetailsRequest): Promise<GetAccountDetailsResponse> {
return this.get<GetAccountDetailsResponse>(`/accounts/${accountId}/details`, accessToken)
}
/**
@ -66,8 +79,11 @@ export class TellerApi {
* https://teller.io/docs/api/account/balances
*/
async getAccountBalances(accountId: string): Promise<GetAccountBalancesResponse> {
return this.get<GetAccountBalancesResponse>(`/accounts/${accountId}/balances`)
async getAccountBalances({
accountId,
accessToken,
}: GetAccountBalancesRequest): Promise<GetAccountBalancesResponse> {
return this.get<GetAccountBalancesResponse>(`/accounts/${accountId}/balances`, accessToken)
}
/**
@ -76,8 +92,11 @@ export class TellerApi {
* https://teller.io/docs/api/transactions
*/
async getTransactions(accountId: string): Promise<GetTransactionsResponse> {
return this.get<GetTransactionsResponse>(`/accounts/${accountId}/transactions`)
async getTransactions({
accountId,
accessToken,
}: GetTransactionsRequest): Promise<GetTransactionsResponse> {
return this.get<GetTransactionsResponse>(`/accounts/${accountId}/transactions`, accessToken)
}
/**
@ -86,12 +105,14 @@ export class TellerApi {
* https://teller.io/docs/api/transactions
*/
async getTransaction(
accountId: string,
transactionId: string
): Promise<GetTransactionResponse> {
async getTransaction({
accountId,
transactionId,
accessToken,
}: GetTransactionRequest): Promise<GetTransactionResponse> {
return this.get<GetTransactionResponse>(
`/accounts/${accountId}/transactions/${transactionId}`
`/accounts/${accountId}/transactions/${transactionId}`,
accessToken
)
}
@ -101,21 +122,21 @@ export class TellerApi {
* https://teller.io/docs/api/identity
*/
async getIdentity(): Promise<GetIdentityResponse> {
return this.get<GetIdentityResponse>(`/identity`)
async getIdentity({ accessToken }: AuthenticatedRequest): Promise<GetIdentityResponse> {
return this.get<GetIdentityResponse>(`/identity`, accessToken)
}
/**
* Get list of supported institutions
* Get list of supported institutions, access token not needed
*
* https://teller.io/docs/api/identity
*/
async getInstitutions(): Promise<GetInstitutionsResponse> {
return this.get<GetInstitutionsResponse>(`/institutions`)
return this.get<GetInstitutionsResponse>(`/institutions`, '')
}
private async getApi(): Promise<AxiosInstance> {
private async getApi(accessToken: string): Promise<AxiosInstance> {
const cert = fs.readFileSync('../../../certs/teller-certificate.pem', 'utf8')
const key = fs.readFileSync('../../../certs/teller-private-key.pem', 'utf8')
@ -133,6 +154,15 @@ export class TellerApi {
Accept: 'application/json',
},
})
this.api.interceptors.request.use((config) => {
// Add the access_token to the auth object
config.auth = {
username: 'ACCESS_TOKEN',
password: accessToken,
}
return config
})
}
return this.api
@ -141,30 +171,33 @@ export class TellerApi {
/** Generic API GET request method */
private async get<TResponse>(
path: string,
accessToken: string,
params?: any,
config?: AxiosRequestConfig
): Promise<TResponse> {
const api = await this.getApi()
const api = await this.getApi(accessToken)
return api.get<TResponse>(path, { params, ...config }).then(({ data }) => data)
}
/** Generic API POST request method */
private async post<TResponse>(
path: string,
accessToken: string,
body?: any,
config?: AxiosRequestConfig
): Promise<TResponse> {
const api = await this.getApi()
const api = await this.getApi(accessToken)
return api.post<TResponse>(path, body, config).then(({ data }) => data)
}
/** Generic API DELETE request method */
private async delete<TResponse>(
path: string,
accessToken: string,
params?: any,
config?: AxiosRequestConfig
): Promise<TResponse> {
const api = await this.getApi()
const api = await this.getApi(accessToken)
return api.delete<TResponse>(path, { params, ...config }).then(({ data }) => data)
}
}

View file

@ -1,4 +1,5 @@
// https://teller.io/docs/api/account/balances
import type { AuthenticatedRequest } from './authentication'
export type AccountBalance = {
account_id: string
@ -11,3 +12,6 @@ export type AccountBalance = {
}
export type GetAccountBalancesResponse = AccountBalance
export interface GetAccountBalancesRequest extends AuthenticatedRequest {
accountId: string
}

View file

@ -1,5 +1,7 @@
// https://teller.io/docs/api/account/details
import type { AuthenticatedRequest } from './authentication'
export type AccountDetails = {
account_id: string
account_number: string
@ -15,3 +17,6 @@ export type AccountDetails = {
}
export type GetAccountDetailsResponse = AccountDetails
export interface GetAccountDetailsRequest extends AuthenticatedRequest {
accountId: string
}

View file

@ -1,7 +1,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

View file

@ -3,3 +3,7 @@
export type AuthenticationResponse = {
token: string
}
export type AuthenticatedRequest = {
accessToken: string
}

View file

@ -0,0 +1,6 @@
export type TellerError = {
error: {
code: string
message: string
}
}

View file

@ -2,6 +2,7 @@ export * from './accounts'
export * from './account-balance'
export * from './account-details'
export * from './authentication'
export * from './error'
export * from './identity'
export * from './institutions'
export * from './transactions'

View file

@ -1,5 +1,7 @@
// https://teller.io/docs/api/account/transactions
import type { AuthenticatedRequest } from './authentication'
type DetailCategory =
| 'accommodation'
| 'advertising'
@ -57,3 +59,10 @@ export type Transaction = {
export type GetTransactionsResponse = Transaction[]
export type GetTransactionResponse = Transaction
export interface GetTransactionsRequest extends AuthenticatedRequest {
accountId: string
}
export interface GetTransactionRequest extends AuthenticatedRequest {
accountId: string
transactionId: string
}

View file

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

View file

@ -0,0 +1,37 @@
/*
Warnings:
- A unique constraint covering the columns `[account_connection_id,teller_account_id]` on the table `account` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[teller_transaction_id]` on the table `transaction` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[teller_user_id]` on the table `user` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterEnum
ALTER TYPE "AccountConnectionType" ADD VALUE 'teller';
-- AlterTable
ALTER TABLE "account" ADD COLUMN "teller_account_id" TEXT,
ADD COLUMN "teller_type" TEXT;
-- AlterTable
ALTER TABLE "account_connection" ADD COLUMN "teller_access_token" TEXT,
ADD COLUMN "teller_account_id" TEXT,
ADD COLUMN "teller_error" JSONB,
ADD COLUMN "teller_institution_id" TEXT;
-- AlterTable
ALTER TABLE "transaction" ADD COLUMN "teller_category" TEXT,
ADD COLUMN "teller_transaction_id" TEXT,
ADD COLUMN "teller_type" TEXT;
-- AlterTable
ALTER TABLE "user" ADD COLUMN "teller_user_id" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "account_account_connection_id_teller_account_id_key" ON "account"("account_connection_id", "teller_account_id");
-- CreateIndex
CREATE UNIQUE INDEX "transaction_teller_transaction_id_key" ON "transaction"("teller_transaction_id");
-- CreateIndex
CREATE UNIQUE INDEX "user_teller_user_id_key" ON "user"("teller_user_id");

View file

@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "AccountProvider" ADD VALUE 'teller';
-- AlterTable
ALTER TABLE "account" ADD COLUMN "teller_subtype" TEXT;

View file

@ -43,6 +43,7 @@ enum AccountSyncStatus {
enum AccountConnectionType {
plaid
finicity
teller
}
model AccountConnection {
@ -69,6 +70,12 @@ model AccountConnection {
finicityInstitutionId String? @map("finicity_institution_id")
finicityError Json? @map("finicity_error")
// teller data
tellerAccountId String? @map("teller_account_id")
tellerAccessToken String? @map("teller_access_token")
tellerInstitutionId String? @map("teller_institution_id")
tellerError Json? @map("teller_error")
accounts Account[]
@@index([userId])
@ -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?