mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 15:35:22 +02:00
Merge branch 'main' of https://github.com/maybe-finance/maybe into main
This commit is contained in:
commit
be27bddb4c
37 changed files with 778 additions and 106 deletions
61
.env.example
61
.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=
|
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -4,6 +4,7 @@ about: Create a report to help us improve
|
|||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
|
|
|
@ -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**
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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'
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
5
apps/workers/src/app/lib/teller.ts
Normal file
5
apps/workers/src/app/lib/teller.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { TellerApi } from '@maybe-finance/teller-api'
|
||||
|
||||
const teller = new TellerApi()
|
||||
|
||||
export default teller
|
|
@ -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'),
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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’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)} />
|
||||
</>
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
export * from './teller.webhook'
|
||||
export * from './teller.service'
|
||||
export * from './teller.etl'
|
||||
|
|
271
libs/server/features/src/providers/teller/teller.etl.ts
Normal file
271
libs/server/features/src/providers/teller/teller.etl.ts
Normal 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(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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']
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
63
libs/server/shared/src/utils/teller-utils.ts
Normal file
63
libs/server/shared/src/utils/teller-utils.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -3,3 +3,7 @@
|
|||
export type AuthenticationResponse = {
|
||||
token: string
|
||||
}
|
||||
|
||||
export type AuthenticatedRequest = {
|
||||
accessToken: string
|
||||
}
|
||||
|
|
6
libs/teller-api/src/types/error.ts
Normal file
6
libs/teller-api/src/types/error.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export type TellerError = {
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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");
|
|
@ -0,0 +1,5 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "AccountProvider" ADD VALUE 'teller';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "account" ADD COLUMN "teller_subtype" TEXT;
|
|
@ -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?
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue