mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
Merge branch 'main' into add-client-storybook
This commit is contained in:
commit
490e1ffb66
99 changed files with 913 additions and 23184 deletions
|
@ -58,5 +58,3 @@ NX_POSTMARK_API_TOKEN=
|
||||||
# for now, they are still required.
|
# for now, they are still required.
|
||||||
########################################################################
|
########################################################################
|
||||||
NX_PLAID_SECRET=
|
NX_PLAID_SECRET=
|
||||||
NX_FINICITY_APP_KEY=
|
|
||||||
NX_FINICITY_PARTNER_SECRET=
|
|
||||||
|
|
14
README.md
14
README.md
|
@ -6,11 +6,11 @@
|
||||||
|
|
||||||
## Backstory
|
## Backstory
|
||||||
|
|
||||||
We spent the better part of 2021/2022 building a personal finance + wealth management app called Maybe. Very full-featured, including an "Ask an Advisor" feature which connected users with an actual CFP/CFA to help them with their finances (all included in your subscription).
|
We spent the better part of 2021/2022 building a personal finance + wealth management app called, Maybe. Very full-featured, including an "Ask an Advisor" feature which connected users with an actual CFP/CFA to help them with their finances (all included in your subscription).
|
||||||
|
|
||||||
The business end of things didn't work out and so we shut things down mid-2023.
|
The business end of things didn't work out, and so we shut things down mid-2023.
|
||||||
|
|
||||||
We spent the better part of $1,000,000 building the app (employees + contractors, data providers/services, infrastructure, etc).
|
We spent the better part of $1,000,000 building the app (employees + contractors, data providers/services, infrastructure, etc.).
|
||||||
|
|
||||||
We're now reviving the product as a fully open-source project. The goal is to let you run the app yourself, for free, and use it to manage your own finances and eventually offer a hosted version of the app for a small monthly fee.
|
We're now reviving the product as a fully open-source project. The goal is to let you run the app yourself, for free, and use it to manage your own finances and eventually offer a hosted version of the app for a small monthly fee.
|
||||||
|
|
||||||
|
@ -37,7 +37,7 @@ And dozens upon dozens of smaller features.
|
||||||
|
|
||||||
This is the current state of building the app. We're actively working to make this process much more streamlined!
|
This is the current state of building the app. We're actively working to make this process much more streamlined!
|
||||||
|
|
||||||
*You'll need Docker installed to run the app locally.*
|
_You'll need Docker installed to run the app locally._
|
||||||
[Docker Desktop](https://www.docker.com/products/docker-desktop/) is an easy way to get started.
|
[Docker Desktop](https://www.docker.com/products/docker-desktop/) is an easy way to get started.
|
||||||
|
|
||||||
First, copy the `.env.example` file to `.env`:
|
First, copy the `.env.example` file to `.env`:
|
||||||
|
@ -53,14 +53,14 @@ To enable transactional emails, you'll need to create a [Postmark](https://postm
|
||||||
Maybe uses [Teller](https://teller.io/) for connecting financial accounts. To get started with Teller, you'll need to create an account. Once you've created an account:
|
Maybe uses [Teller](https://teller.io/) for connecting financial accounts. To get started with Teller, you'll need to create an account. Once you've created an account:
|
||||||
|
|
||||||
- Add your Teller application id to your `.env` file (`NEXT_PUBLIC_TELLER_APP_ID`).
|
- Add your Teller application id to your `.env` file (`NEXT_PUBLIC_TELLER_APP_ID`).
|
||||||
- Download your authentication certificates from Teller, create a `certs` folder in the root of the project, and place your certs in that directory. You should have both a `certificate.pem` and `private_key.pem`. **NEVER** check these files into source control, the `.gitignore` file will prevent the `certs/` directory from being added, but please double check.
|
- Download your authentication certificates from Teller, create a `certs` folder in the root of the project, and place your certs in that directory. You should have both a `certificate.pem` and `private_key.pem`. **NEVER** check these files into source control, the `.gitignore` file will prevent the `certs/` directory from being added, but please double-check.
|
||||||
- Set your `NEXT_PUBLIC_TELLER_ENV` and `NX_TELLER_ENV` to your desired environment. The default is `sandbox` which allows for testing with mock data. The login credentials for the sandbox environment are `username` and `password`. To connect to real financial accounts, you'll need to use the `development` environment.
|
- Set your `NEXT_PUBLIC_TELLER_ENV` and `NX_TELLER_ENV` to your desired environment. The default is `sandbox` which allows for testing with mock data. The login credentials for the sandbox environment are `username` and `password`. To connect to real financial accounts, you'll need to use the `development` environment.
|
||||||
- Webhooks are not implemented yet, but you can populate the `NX_TELLER_SIGNING_SECRET` with the value from your Teller account.
|
- Webhooks are not implemented yet, but you can populate the `NX_TELLER_SIGNING_SECRET` with the value from your Teller account.
|
||||||
- We highly recommend checking out the [Teller docs](https://teller.io/docs) for more info.
|
- We highly recommend checking out the [Teller docs](https://teller.io/docs) for more info.
|
||||||
|
|
||||||
Then run the following yarn commands:
|
Then run the following yarn commands:
|
||||||
|
|
||||||
```
|
```shell
|
||||||
yarn install
|
yarn install
|
||||||
yarn run dev:services:all
|
yarn run dev:services:all
|
||||||
yarn prisma:migrate:dev
|
yarn prisma:migrate:dev
|
||||||
|
@ -95,7 +95,7 @@ To contribute, please see our [contribution guide](https://github.com/maybe-fina
|
||||||
|
|
||||||
## High-priority issues
|
## High-priority issues
|
||||||
|
|
||||||
The biggest focus at the moment is on getting the app functional without some previously key external services (namely Plaid and Finicity).
|
The biggest focus at the moment is on getting the app functional without some previously key external services (namely Plaid).
|
||||||
|
|
||||||
You can view the current [high-priority issues here](https://github.com/maybe-finance/maybe/issues?q=is:issue+is:open+label:%22high+priority%22). Those are the most impactful issues to tackle first.
|
You can view the current [high-priority issues here](https://github.com/maybe-finance/maybe/issues?q=is:issue+is:open+label:%22high+priority%22). Those are the most impactful issues to tackle first.
|
||||||
|
|
||||||
|
|
|
@ -33,16 +33,18 @@ Sentry.init({
|
||||||
|
|
||||||
// Providers and components only relevant to a logged-in user
|
// Providers and components only relevant to a logged-in user
|
||||||
const WithAuth = function ({ children }: PropsWithChildren) {
|
const WithAuth = function ({ children }: PropsWithChildren) {
|
||||||
const { data: session } = useSession()
|
const { data: session, status } = useSession()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (status === 'loading') return
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
}, [session, router])
|
}, [session, status, router])
|
||||||
|
|
||||||
if (session) {
|
if (session && status === 'authenticated') {
|
||||||
return (
|
return (
|
||||||
<OnboardingGuard>
|
<OnboardingGuard>
|
||||||
<UserAccountContextProvider>
|
<UserAccountContextProvider>
|
||||||
|
|
|
@ -12,6 +12,7 @@ export default function LoginPage() {
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [isValid, setIsValid] = useState(false)
|
const [isValid, setIsValid] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
@ -26,6 +27,7 @@ export default function LoginPage() {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setErrorMessage(null)
|
setErrorMessage(null)
|
||||||
setPassword('')
|
setPassword('')
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
const response = await signIn('credentials', {
|
const response = await signIn('credentials', {
|
||||||
email,
|
email,
|
||||||
|
@ -35,9 +37,17 @@ export default function LoginPage() {
|
||||||
|
|
||||||
if (response && response.error) {
|
if (response && response.error) {
|
||||||
setErrorMessage(response.error)
|
setErrorMessage(response.error)
|
||||||
|
setIsLoading(false)
|
||||||
|
setIsValid(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setErrorMessage(null)
|
||||||
|
setPassword(e.target.value)
|
||||||
|
setIsValid(e.target.value.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Script
|
<Script
|
||||||
|
@ -68,15 +78,8 @@ export default function LoginPage() {
|
||||||
name="password"
|
name="password"
|
||||||
label="Password"
|
label="Password"
|
||||||
value={password}
|
value={password}
|
||||||
showPasswordRequirements={!isValid}
|
onChange={onPasswordChange}
|
||||||
onValidityChange={(checks) => {
|
showComplexityBar={false}
|
||||||
const passwordValid =
|
|
||||||
checks.filter((c) => !c.isValid).length === 0
|
|
||||||
setIsValid(passwordValid)
|
|
||||||
}}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setPassword(e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{errorMessage && password.length === 0 ? (
|
{errorMessage && password.length === 0 ? (
|
||||||
|
@ -87,8 +90,9 @@ export default function LoginPage() {
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!isValid}
|
disabled={!isValid || isLoading}
|
||||||
variant={isValid ? 'primary' : 'secondary'}
|
variant={isValid ? 'primary' : 'secondary'}
|
||||||
|
isLoading={isLoading}
|
||||||
>
|
>
|
||||||
Log in
|
Log in
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -14,6 +14,7 @@ export default function RegisterPage() {
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [isValid, setIsValid] = useState(false)
|
const [isValid, setIsValid] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
@ -30,6 +31,7 @@ export default function RegisterPage() {
|
||||||
setLastName('')
|
setLastName('')
|
||||||
setEmail('')
|
setEmail('')
|
||||||
setPassword('')
|
setPassword('')
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
const response = await signIn('credentials', {
|
const response = await signIn('credentials', {
|
||||||
email,
|
email,
|
||||||
|
@ -41,6 +43,7 @@ export default function RegisterPage() {
|
||||||
|
|
||||||
if (response && response.error) {
|
if (response && response.error) {
|
||||||
setErrorMessage(response.error)
|
setErrorMessage(response.error)
|
||||||
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,6 +112,7 @@ export default function RegisterPage() {
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
variant={isValid ? 'primary' : 'secondary'}
|
variant={isValid ? 'primary' : 'secondary'}
|
||||||
|
isLoading={isLoading}
|
||||||
>
|
>
|
||||||
Register
|
Register
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -75,9 +75,5 @@ module.exports = merge(designSystemConfig, {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
|
||||||
require('@tailwindcss/line-clamp'),
|
|
||||||
require('@tailwindcss/forms'),
|
|
||||||
require('@tailwindcss/typography'),
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { User } from '@prisma/client'
|
import type { User } from '@prisma/client'
|
||||||
|
import { InvestmentTransactionCategory } from '@prisma/client'
|
||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
import { createLogger, transports } from 'winston'
|
import { createLogger, transports } from 'winston'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
|
@ -131,6 +132,7 @@ describe('balance sync strategies', () => {
|
||||||
quantity: 10,
|
quantity: 10,
|
||||||
price: 10,
|
price: 10,
|
||||||
plaidType: 'buy',
|
plaidType: 'buy',
|
||||||
|
category: InvestmentTransactionCategory.buy,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: DateTime.fromISO('2023-02-04').toJSDate(),
|
date: DateTime.fromISO('2023-02-04').toJSDate(),
|
||||||
|
@ -140,6 +142,7 @@ describe('balance sync strategies', () => {
|
||||||
quantity: 5,
|
quantity: 5,
|
||||||
price: 10,
|
price: 10,
|
||||||
plaidType: 'sell',
|
plaidType: 'sell',
|
||||||
|
category: InvestmentTransactionCategory.sell,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: DateTime.fromISO('2023-02-04').toJSDate(),
|
date: DateTime.fromISO('2023-02-04').toJSDate(),
|
||||||
|
@ -147,6 +150,7 @@ describe('balance sync strategies', () => {
|
||||||
amount: 50,
|
amount: 50,
|
||||||
quantity: 50,
|
quantity: 50,
|
||||||
price: 1,
|
price: 1,
|
||||||
|
category: InvestmentTransactionCategory.other,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,21 +1,14 @@
|
||||||
import type { AxiosInstance } from 'axios'
|
import type { AxiosInstance } from 'axios'
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
import type { Prisma, AccountConnection, User } from '@prisma/client'
|
||||||
import type { Prisma, AccountConnection, AccountSyncStatus, User } from '@prisma/client'
|
import { AccountConnectionType, AccountSyncStatus } from '@prisma/client'
|
||||||
import type { ItemRemoveResponse } from 'plaid'
|
|
||||||
import { startServer, stopServer } from './utils/server'
|
import { startServer, stopServer } from './utils/server'
|
||||||
import { getAxiosClient } from './utils/axios'
|
import { getAxiosClient } from './utils/axios'
|
||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
import { TestUtil } from '@maybe-finance/shared'
|
|
||||||
import { InMemoryQueue } from '@maybe-finance/server/shared'
|
import { InMemoryQueue } from '@maybe-finance/server/shared'
|
||||||
import { default as _plaid } from '../lib/plaid'
|
|
||||||
import nock from 'nock'
|
import nock from 'nock'
|
||||||
import { resetUser } from './utils/user'
|
import { resetUser } from './utils/user'
|
||||||
|
|
||||||
jest.mock('../middleware/validate-plaid-jwt.ts')
|
jest.mock('../lib/teller.ts')
|
||||||
jest.mock('plaid')
|
|
||||||
|
|
||||||
// For TypeScript support
|
|
||||||
const plaid = jest.mocked(_plaid)
|
|
||||||
|
|
||||||
const authId = '__TEST_USER_ID__'
|
const authId = '__TEST_USER_ID__'
|
||||||
let axios: AxiosInstance
|
let axios: AxiosInstance
|
||||||
|
@ -49,13 +42,13 @@ beforeEach(async () => {
|
||||||
connectionData = {
|
connectionData = {
|
||||||
data: {
|
data: {
|
||||||
name: 'Chase Test',
|
name: 'Chase Test',
|
||||||
type: 'plaid' as SharedType.AccountConnectionType,
|
type: AccountConnectionType.teller,
|
||||||
plaidItemId: 'test-plaid-item-server',
|
tellerEnrollmentId: 'test-teller-item-workers',
|
||||||
plaidInstitutionId: 'ins_3',
|
tellerInstitutionId: 'chase_test',
|
||||||
plaidAccessToken:
|
tellerAccessToken:
|
||||||
'U2FsdGVkX1+WMq9lfTS9Zkbgrn41+XT1hvSK5ain/udRPujzjVCAx/lyPG7EumVZA+nVKXPauGwI+d7GZgtqTA9R3iCZNusU6LFPnmFOCE4=',
|
'U2FsdGVkX1+WMq9lfTS9Zkbgrn41+XT1hvSK5ain/udRPujzjVCAx/lyPG7EumVZA+nVKXPauGwI+d7GZgtqTA9R3iCZNusU6LFPnmFOCE4=', // need correct encoding here
|
||||||
userId: user!.id,
|
userId: user.id,
|
||||||
syncStatus: 'PENDING' as AccountSyncStatus,
|
syncStatus: AccountSyncStatus.PENDING,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,16 +84,9 @@ describe('/v1/connections API', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('DELETE /:id', async () => {
|
it('DELETE /:id', async () => {
|
||||||
plaid.itemRemove.mockResolvedValueOnce(
|
|
||||||
TestUtil.axiosSuccess<ItemRemoveResponse>({
|
|
||||||
request_id: 'test request id',
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const res = await axios.delete<AccountConnection>(`/connections/${connection.id}`)
|
const res = await axios.delete<AccountConnection>(`/connections/${connection.id}`)
|
||||||
|
|
||||||
expect(res.status).toEqual(200)
|
expect(res.status).toEqual(200)
|
||||||
expect(plaid.itemRemove).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
const res2 = await axios.get<AccountConnection>(`/connections/${connection.id}`)
|
const res2 = await axios.get<AccountConnection>(`/connections/${connection.id}`)
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { User } from '@prisma/client'
|
import type { User } from '@prisma/client'
|
||||||
import { Prisma, PrismaClient } from '@prisma/client'
|
import { InvestmentTransactionCategory, Prisma, PrismaClient } from '@prisma/client'
|
||||||
import { createLogger, transports } from 'winston'
|
import { createLogger, transports } from 'winston'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import type {
|
import type {
|
||||||
|
@ -307,6 +307,7 @@ describe('insight service', () => {
|
||||||
price: 100,
|
price: 100,
|
||||||
plaidType: 'buy',
|
plaidType: 'buy',
|
||||||
plaidSubtype: 'buy',
|
plaidSubtype: 'buy',
|
||||||
|
category: InvestmentTransactionCategory.buy,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
|
@ -318,6 +319,7 @@ describe('insight service', () => {
|
||||||
price: 200,
|
price: 200,
|
||||||
plaidType: 'buy',
|
plaidType: 'buy',
|
||||||
plaidSubtype: 'buy',
|
plaidSubtype: 'buy',
|
||||||
|
category: InvestmentTransactionCategory.buy,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
|
@ -329,6 +331,7 @@ describe('insight service', () => {
|
||||||
price: 0,
|
price: 0,
|
||||||
plaidType: 'cash',
|
plaidType: 'cash',
|
||||||
plaidSubtype: 'dividend',
|
plaidSubtype: 'dividend',
|
||||||
|
category: InvestmentTransactionCategory.dividend,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
|
@ -340,6 +343,7 @@ describe('insight service', () => {
|
||||||
price: 0,
|
price: 0,
|
||||||
plaidType: 'cash',
|
plaidType: 'cash',
|
||||||
plaidSubtype: 'dividend',
|
plaidSubtype: 'dividend',
|
||||||
|
category: InvestmentTransactionCategory.dividend,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { DateTime } from 'luxon'
|
||||||
import { PgService } from '@maybe-finance/server/shared'
|
import { PgService } from '@maybe-finance/server/shared'
|
||||||
import { AccountQueryService, UserService } from '@maybe-finance/server/features'
|
import { AccountQueryService, UserService } from '@maybe-finance/server/features'
|
||||||
import { resetUser } from './utils/user'
|
import { resetUser } from './utils/user'
|
||||||
jest.mock('plaid')
|
|
||||||
|
|
||||||
const prisma = new PrismaClient()
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { PrismaClient, User } from '@prisma/client'
|
import type { PrismaClient, User } from '@prisma/client'
|
||||||
import { Prisma } from '@prisma/client'
|
import { InvestmentTransactionCategory, Prisma } from '@prisma/client'
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import { parseCsv } from './csv'
|
import { parseCsv } from './csv'
|
||||||
|
@ -20,6 +20,14 @@ const portfolios: Record<string, Partial<Prisma.AccountUncheckedCreateInput>> =
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const investmentTransactionCategoryByType: Record<string, InvestmentTransactionCategory> = {
|
||||||
|
BUY: InvestmentTransactionCategory.buy,
|
||||||
|
SELL: InvestmentTransactionCategory.sell,
|
||||||
|
DIVIDEND: InvestmentTransactionCategory.dividend,
|
||||||
|
DEPOSIT: InvestmentTransactionCategory.transfer,
|
||||||
|
WITHDRAW: InvestmentTransactionCategory.transfer,
|
||||||
|
}
|
||||||
|
|
||||||
export async function createTestInvestmentAccount(
|
export async function createTestInvestmentAccount(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
user: User,
|
user: User,
|
||||||
|
@ -35,7 +43,7 @@ export async function createTestInvestmentAccount(
|
||||||
join(__dirname, `../test-data/${portfolio}/holdings.csv`)
|
join(__dirname, `../test-data/${portfolio}/holdings.csv`)
|
||||||
)
|
)
|
||||||
|
|
||||||
const [_deleted, ...securities] = await prisma.$transaction([
|
const [, ...securities] = await prisma.$transaction([
|
||||||
prisma.security.deleteMany({
|
prisma.security.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
symbol: {
|
symbol: {
|
||||||
|
@ -72,7 +80,7 @@ export async function createTestInvestmentAccount(
|
||||||
.value(),
|
.value(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const account = await prisma.account.create({
|
return prisma.account.create({
|
||||||
data: {
|
data: {
|
||||||
...portfolios[portfolio],
|
...portfolios[portfolio],
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
@ -128,12 +136,13 @@ export async function createTestInvestmentAccount(
|
||||||
: it.type === 'SELL'
|
: it.type === 'SELL'
|
||||||
? 'sell'
|
? 'sell'
|
||||||
: undefined,
|
: undefined,
|
||||||
|
category:
|
||||||
|
investmentTransactionCategoryByType[it.type] ??
|
||||||
|
InvestmentTransactionCategory.other,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return account
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,7 +35,6 @@ import {
|
||||||
accountRollupRouter,
|
accountRollupRouter,
|
||||||
valuationsRouter,
|
valuationsRouter,
|
||||||
institutionsRouter,
|
institutionsRouter,
|
||||||
finicityRouter,
|
|
||||||
tellerRouter,
|
tellerRouter,
|
||||||
transactionsRouter,
|
transactionsRouter,
|
||||||
holdingsRouter,
|
holdingsRouter,
|
||||||
|
@ -110,7 +109,7 @@ app.use(
|
||||||
app.use('/v1/stripe', express.raw({ type: 'application/json' }))
|
app.use('/v1/stripe', express.raw({ type: 'application/json' }))
|
||||||
|
|
||||||
app.use(express.urlencoded({ extended: true }))
|
app.use(express.urlencoded({ extended: true }))
|
||||||
app.use(express.json({ limit: '50mb' })) // Finicity sends large response bodies for webhooks
|
app.use(express.json())
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// API ⬇️
|
// API ⬇️
|
||||||
|
@ -158,7 +157,6 @@ app.use('/v1', validateAuthJwt)
|
||||||
app.use('/v1/users', usersRouter)
|
app.use('/v1/users', usersRouter)
|
||||||
app.use('/v1/e2e', e2eRouter)
|
app.use('/v1/e2e', e2eRouter)
|
||||||
app.use('/v1/plaid', plaidRouter)
|
app.use('/v1/plaid', plaidRouter)
|
||||||
app.use('/v1/finicity', finicityRouter)
|
|
||||||
app.use('/v1/teller', tellerRouter)
|
app.use('/v1/teller', tellerRouter)
|
||||||
app.use('/v1/accounts', accountsRouter)
|
app.use('/v1/accounts', accountsRouter)
|
||||||
app.use('/v1/account-rollup', accountRollupRouter)
|
app.use('/v1/account-rollup', accountRollupRouter)
|
||||||
|
|
|
@ -37,10 +37,7 @@ import {
|
||||||
TransactionBalanceSyncStrategy,
|
TransactionBalanceSyncStrategy,
|
||||||
InvestmentTransactionBalanceSyncStrategy,
|
InvestmentTransactionBalanceSyncStrategy,
|
||||||
PlaidETL,
|
PlaidETL,
|
||||||
FinicityService,
|
|
||||||
FinicityETL,
|
|
||||||
InstitutionProviderFactory,
|
InstitutionProviderFactory,
|
||||||
FinicityWebhookHandler,
|
|
||||||
PlaidWebhookHandler,
|
PlaidWebhookHandler,
|
||||||
TellerService,
|
TellerService,
|
||||||
TellerETL,
|
TellerETL,
|
||||||
|
@ -56,7 +53,6 @@ import {
|
||||||
} from '@maybe-finance/server/features'
|
} from '@maybe-finance/server/features'
|
||||||
import prisma from './prisma'
|
import prisma from './prisma'
|
||||||
import plaid, { getPlaidWebhookUrl } from './plaid'
|
import plaid, { getPlaidWebhookUrl } from './plaid'
|
||||||
import finicity, { getFinicityTxPushUrl, getFinicityWebhookUrl } from './finicity'
|
|
||||||
import teller, { getTellerWebhookUrl } from './teller'
|
import teller, { getTellerWebhookUrl } from './teller'
|
||||||
import stripe from './stripe'
|
import stripe from './stripe'
|
||||||
import postmark from './postmark'
|
import postmark from './postmark'
|
||||||
|
@ -136,15 +132,6 @@ const plaidService = new PlaidService(
|
||||||
env.NX_CLIENT_URL_CUSTOM || env.NX_CLIENT_URL
|
env.NX_CLIENT_URL_CUSTOM || env.NX_CLIENT_URL
|
||||||
)
|
)
|
||||||
|
|
||||||
const finicityService = new FinicityService(
|
|
||||||
logger.child({ service: 'FinicityService' }),
|
|
||||||
prisma,
|
|
||||||
finicity,
|
|
||||||
new FinicityETL(logger.child({ service: 'FinicityETL' }), prisma, finicity),
|
|
||||||
getFinicityWebhookUrl(),
|
|
||||||
env.NX_FINICITY_ENV === 'sandbox'
|
|
||||||
)
|
|
||||||
|
|
||||||
const tellerService = new TellerService(
|
const tellerService = new TellerService(
|
||||||
logger.child({ service: 'TellerService' }),
|
logger.child({ service: 'TellerService' }),
|
||||||
prisma,
|
prisma,
|
||||||
|
@ -159,7 +146,6 @@ const tellerService = new TellerService(
|
||||||
|
|
||||||
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
|
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
|
||||||
plaid: plaidService,
|
plaid: plaidService,
|
||||||
finicity: finicityService,
|
|
||||||
teller: tellerService,
|
teller: tellerService,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -239,7 +225,6 @@ const userService = new UserService(
|
||||||
|
|
||||||
const institutionProviderFactory = new InstitutionProviderFactory({
|
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||||
PLAID: plaidService,
|
PLAID: plaidService,
|
||||||
FINICITY: finicityService,
|
|
||||||
TELLER: tellerService,
|
TELLER: tellerService,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -279,14 +264,6 @@ const plaidWebhooks = new PlaidWebhookHandler(
|
||||||
queueService
|
queueService
|
||||||
)
|
)
|
||||||
|
|
||||||
const finicityWebhooks = new FinicityWebhookHandler(
|
|
||||||
logger.child({ service: 'FinicityWebhookHandler' }),
|
|
||||||
prisma,
|
|
||||||
finicity,
|
|
||||||
accountConnectionService,
|
|
||||||
getFinicityTxPushUrl()
|
|
||||||
)
|
|
||||||
|
|
||||||
const stripeWebhooks = new StripeWebhookHandler(
|
const stripeWebhooks = new StripeWebhookHandler(
|
||||||
logger.child({ service: 'StripeWebhookHandler' }),
|
logger.child({ service: 'StripeWebhookHandler' }),
|
||||||
prisma,
|
prisma,
|
||||||
|
@ -358,8 +335,6 @@ export async function createContext(req: Request) {
|
||||||
queueService,
|
queueService,
|
||||||
plaidService,
|
plaidService,
|
||||||
plaidWebhooks,
|
plaidWebhooks,
|
||||||
finicityService,
|
|
||||||
finicityWebhooks,
|
|
||||||
stripeWebhooks,
|
stripeWebhooks,
|
||||||
tellerService,
|
tellerService,
|
||||||
tellerWebhooks,
|
tellerWebhooks,
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
import { FinicityApi } from '@maybe-finance/finicity-api'
|
|
||||||
import { getWebhookUrl } from './webhook'
|
|
||||||
import env from '../../env'
|
|
||||||
|
|
||||||
const finicity = new FinicityApi(
|
|
||||||
env.NX_FINICITY_APP_KEY,
|
|
||||||
env.NX_FINICITY_PARTNER_ID,
|
|
||||||
env.NX_FINICITY_PARTNER_SECRET
|
|
||||||
)
|
|
||||||
|
|
||||||
export default finicity
|
|
||||||
|
|
||||||
export async function getFinicityWebhookUrl() {
|
|
||||||
const webhookUrl = await getWebhookUrl()
|
|
||||||
return `${webhookUrl}/v1/finicity/webhook`
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getFinicityTxPushUrl() {
|
|
||||||
const webhookUrl = await getWebhookUrl()
|
|
||||||
return `${webhookUrl}/v1/finicity/txpush`
|
|
||||||
}
|
|
|
@ -4,7 +4,6 @@ export * from './auth-error-handler'
|
||||||
export * from './superjson'
|
export * from './superjson'
|
||||||
export * from './validate-auth-jwt'
|
export * from './validate-auth-jwt'
|
||||||
export * from './validate-plaid-jwt'
|
export * from './validate-plaid-jwt'
|
||||||
export * from './validate-finicity-signature'
|
|
||||||
export * from './validate-teller-signature'
|
export * from './validate-teller-signature'
|
||||||
export { default as maintenance } from './maintenance'
|
export { default as maintenance } from './maintenance'
|
||||||
export * from './identify-user'
|
export * from './identify-user'
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
import type { RequestHandler } from 'express'
|
|
||||||
import crypto from 'crypto'
|
|
||||||
import env from '../../env'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* middleware to validate the `x-finicity-signature` header
|
|
||||||
*
|
|
||||||
* https://docs.finicity.com/connect-and-mvs-webhooks/
|
|
||||||
*/
|
|
||||||
export const validateFinicitySignature: RequestHandler = (req, res, next) => {
|
|
||||||
const signature = crypto
|
|
||||||
.createHmac('sha256', env.NX_FINICITY_PARTNER_SECRET)
|
|
||||||
.update(JSON.stringify(req.body))
|
|
||||||
.digest('hex')
|
|
||||||
|
|
||||||
if (req.get('x-finicity-signature') !== signature) {
|
|
||||||
throw new Error('invalid finicity signature')
|
|
||||||
}
|
|
||||||
|
|
||||||
next()
|
|
||||||
}
|
|
|
@ -99,20 +99,6 @@ router.post(
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/:id/finicity/fix-connect',
|
|
||||||
endpoint.create({
|
|
||||||
resolve: async ({ ctx, req }) => {
|
|
||||||
const { link } = await ctx.finicityService.generateFixConnectUrl(
|
|
||||||
ctx.user!.id,
|
|
||||||
+req.params.id
|
|
||||||
)
|
|
||||||
|
|
||||||
return { link }
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/:id/sync',
|
'/:id/sync',
|
||||||
endpoint.create({
|
endpoint.create({
|
||||||
|
|
|
@ -1,31 +0,0 @@
|
||||||
import { z } from 'zod'
|
|
||||||
import { Router } from 'express'
|
|
||||||
import endpoint from '../lib/endpoint'
|
|
||||||
|
|
||||||
const router = Router()
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/connect-url',
|
|
||||||
endpoint.create({
|
|
||||||
input: z.object({
|
|
||||||
institutionId: z.string(),
|
|
||||||
}),
|
|
||||||
resolve: async ({ ctx, input }) => {
|
|
||||||
return await ctx.finicityService.generateConnectUrl(ctx.user!.id, input.institutionId)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/institutions/sync',
|
|
||||||
endpoint.create({
|
|
||||||
resolve: async ({ ctx }) => {
|
|
||||||
ctx.ability.throwUnlessCan('manage', 'Institution')
|
|
||||||
await ctx.queueService
|
|
||||||
.getQueue('sync-institution')
|
|
||||||
.add('sync-finicity-institutions', {})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
export default router
|
|
|
@ -4,7 +4,6 @@ export { default as connectionsRouter } from './connections.router'
|
||||||
export { default as usersRouter } from './users.router'
|
export { default as usersRouter } from './users.router'
|
||||||
export { default as webhooksRouter } from './webhooks.router'
|
export { default as webhooksRouter } from './webhooks.router'
|
||||||
export { default as plaidRouter } from './plaid.router'
|
export { default as plaidRouter } from './plaid.router'
|
||||||
export { default as finicityRouter } from './finicity.router'
|
|
||||||
export { default as tellerRouter } from './teller.router'
|
export { default as tellerRouter } from './teller.router'
|
||||||
export { default as valuationsRouter } from './valuations.router'
|
export { default as valuationsRouter } from './valuations.router'
|
||||||
export { default as institutionsRouter } from './institutions.router'
|
export { default as institutionsRouter } from './institutions.router'
|
||||||
|
|
|
@ -27,11 +27,10 @@ router.post(
|
||||||
resolve: async ({ ctx }) => {
|
resolve: async ({ ctx }) => {
|
||||||
ctx.ability.throwUnlessCan('update', 'Institution')
|
ctx.ability.throwUnlessCan('update', 'Institution')
|
||||||
|
|
||||||
// Sync all Plaid + Finicity institutions
|
// Sync all Plaid institutions
|
||||||
await ctx.queueService.getQueue('sync-institution').addBulk([
|
await ctx.queueService
|
||||||
{ name: 'sync-plaid-institutions', data: {} },
|
.getQueue('sync-institution')
|
||||||
{ name: 'sync-finicity-institutions', data: {} },
|
.addBulk([{ name: 'sync-plaid-institutions', data: {} }])
|
||||||
])
|
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { FinicityTypes } from '@maybe-finance/finicity-api'
|
import { validatePlaidJwt, validateTellerSignature } from '../middleware'
|
||||||
import { validatePlaidJwt, validateFinicitySignature, validateTellerSignature } from '../middleware'
|
|
||||||
import endpoint from '../lib/endpoint'
|
import endpoint from '../lib/endpoint'
|
||||||
import stripe from '../lib/stripe'
|
import stripe from '../lib/stripe'
|
||||||
import env from '../../env'
|
import env from '../../env'
|
||||||
|
@ -35,74 +34,6 @@ router.post(
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/finicity/webhook',
|
|
||||||
process.env.NODE_ENV !== 'development'
|
|
||||||
? validateFinicitySignature
|
|
||||||
: (_req, _res, next) => next(),
|
|
||||||
endpoint.create({
|
|
||||||
input: z
|
|
||||||
.object({
|
|
||||||
eventType: z.string(),
|
|
||||||
eventId: z.string().optional(),
|
|
||||||
customerId: z.string().optional(),
|
|
||||||
payload: z.record(z.any()).optional(),
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
async resolve({ input, ctx }) {
|
|
||||||
const { eventType, eventId, customerId } = input
|
|
||||||
|
|
||||||
ctx.logger.info(
|
|
||||||
`rx[finicity_webhook] event eventType=${eventType} eventId=${eventId} customerId=${customerId}`
|
|
||||||
)
|
|
||||||
|
|
||||||
// May contain sensitive info, only print at the debug level
|
|
||||||
ctx.logger.debug(`rx[finicity_webhook] event payload`, input)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ctx.finicityWebhooks.handleWebhook(input as FinicityTypes.WebhookData)
|
|
||||||
} catch (err) {
|
|
||||||
// record error but don't throw, otherwise Finicity Connect behaves weird
|
|
||||||
ctx.logger.error(`[finicity_webhook] error handling webhook`, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: 'ok' }
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
router.get('/finicity/txpush', (req, res) => {
|
|
||||||
const { txpush_verification_code } = req.query
|
|
||||||
if (!txpush_verification_code) {
|
|
||||||
return res.status(400).send('request missing txpush_verification_code')
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).contentType('text/plain').send(txpush_verification_code)
|
|
||||||
})
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/finicity/txpush',
|
|
||||||
endpoint.create({
|
|
||||||
input: z
|
|
||||||
.object({
|
|
||||||
event: z.record(z.any()), // for now we'll just cast this to the appropriate type
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
async resolve({ input: { event }, ctx }) {
|
|
||||||
const ev = event as FinicityTypes.TxPushEvent
|
|
||||||
|
|
||||||
ctx.logger.info(`rx[finicity_txpush] event class=${ev.class} type=${ev.type}`)
|
|
||||||
|
|
||||||
// May contain sensitive info, only print at the debug level
|
|
||||||
ctx.logger.debug(`rx[finicity_txpush] event payload`, event)
|
|
||||||
|
|
||||||
await ctx.finicityWebhooks.handleTxPushEvent(ev)
|
|
||||||
|
|
||||||
return { status: 'ok' }
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/stripe/webhook',
|
'/stripe/webhook',
|
||||||
endpoint.create({
|
endpoint.create({
|
||||||
|
|
|
@ -36,11 +36,6 @@ const envSchema = z.object({
|
||||||
NX_PLAID_SECRET: z.string(),
|
NX_PLAID_SECRET: z.string(),
|
||||||
NX_PLAID_ENV: z.string().default('sandbox'),
|
NX_PLAID_ENV: z.string().default('sandbox'),
|
||||||
|
|
||||||
NX_FINICITY_APP_KEY: z.string(),
|
|
||||||
NX_FINICITY_PARTNER_ID: z.string().default('REPLACE_THIS'),
|
|
||||||
NX_FINICITY_PARTNER_SECRET: z.string(),
|
|
||||||
NX_FINICITY_ENV: z.string().default('sandbox'),
|
|
||||||
|
|
||||||
NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'),
|
NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'),
|
||||||
NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'),
|
NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'),
|
||||||
NX_TELLER_ENV: z.string().default('sandbox'),
|
NX_TELLER_ENV: z.string().default('sandbox'),
|
||||||
|
|
|
@ -1,317 +0,0 @@
|
||||||
import fs from 'fs'
|
|
||||||
import type { User } from '@prisma/client'
|
|
||||||
import { FinicityTestData } from '../../../../../tools/test-data'
|
|
||||||
import { FinicityApi, type FinicityTypes } from '@maybe-finance/finicity-api'
|
|
||||||
jest.mock('@maybe-finance/finicity-api')
|
|
||||||
import {
|
|
||||||
FinicityETL,
|
|
||||||
FinicityService,
|
|
||||||
type IAccountConnectionProvider,
|
|
||||||
} from '@maybe-finance/server/features'
|
|
||||||
import { createLogger, etl } from '@maybe-finance/server/shared'
|
|
||||||
import prisma from '../lib/prisma'
|
|
||||||
import { resetUser } from './helpers/user.test-helper'
|
|
||||||
import { transports } from 'winston'
|
|
||||||
|
|
||||||
const logger = createLogger({ level: 'debug', transports: [new transports.Console()] })
|
|
||||||
const finicity = jest.mocked(new FinicityApi('APP_KEY', 'PARTNER_ID', 'PARTNER_SECRET'))
|
|
||||||
|
|
||||||
/** mock implementation of finicity's pagination logic which as of writing uses 1-based indexing */
|
|
||||||
function finicityPaginate<T>(data: T[], start = 1, limit = 1_000): T[] {
|
|
||||||
const startIdx = Math.max(0, start - 1)
|
|
||||||
return data.slice(startIdx, startIdx + limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Finicity', () => {
|
|
||||||
let user: User
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
jest.clearAllMocks()
|
|
||||||
|
|
||||||
user = await resetUser(prisma)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('syncs connection', async () => {
|
|
||||||
finicity.getCustomerAccounts.mockResolvedValue({ accounts: FinicityTestData.accounts })
|
|
||||||
|
|
||||||
finicity.getAccountTransactions.mockImplementation(({ accountId, start, limit }) => {
|
|
||||||
const transactions = FinicityTestData.transactions.filter(
|
|
||||||
(t) => t.accountId === +accountId
|
|
||||||
)
|
|
||||||
|
|
||||||
const page = finicityPaginate(transactions, start, limit)
|
|
||||||
|
|
||||||
return Promise.resolve({
|
|
||||||
transactions: page,
|
|
||||||
found: transactions.length,
|
|
||||||
displaying: page.length,
|
|
||||||
moreAvailable: page.length < transactions.length ? 'true' : 'false',
|
|
||||||
fromDate: '1588939200',
|
|
||||||
toDate: '1651492800',
|
|
||||||
sort: 'desc',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const finicityETL = new FinicityETL(logger, prisma, finicity)
|
|
||||||
|
|
||||||
const service: IAccountConnectionProvider = new FinicityService(
|
|
||||||
logger,
|
|
||||||
prisma,
|
|
||||||
finicity,
|
|
||||||
finicityETL,
|
|
||||||
'',
|
|
||||||
true
|
|
||||||
)
|
|
||||||
|
|
||||||
const connection = await prisma.accountConnection.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name: 'TEST_FINICITY',
|
|
||||||
type: 'finicity',
|
|
||||||
finicityInstitutionId: '101732',
|
|
||||||
finicityInstitutionLoginId: '6000483842',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await service.sync(connection)
|
|
||||||
|
|
||||||
const { accounts } = await prisma.accountConnection.findUniqueOrThrow({
|
|
||||||
where: {
|
|
||||||
id: connection.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
accounts: {
|
|
||||||
include: {
|
|
||||||
transactions: true,
|
|
||||||
investmentTransactions: true,
|
|
||||||
holdings: true,
|
|
||||||
valuations: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(accounts).toHaveLength(FinicityTestData.accounts.length)
|
|
||||||
|
|
||||||
// eslint-disable-next-line
|
|
||||||
const [auto, mortgage, roth, brokerage, loc, credit, savings, checking] =
|
|
||||||
FinicityTestData.accounts
|
|
||||||
|
|
||||||
// mortgage
|
|
||||||
const mortgageAccount = accounts.find((a) => a.finicityAccountId === mortgage.id)!
|
|
||||||
expect(mortgageAccount.transactions).toHaveLength(
|
|
||||||
FinicityTestData.transactions.filter((t) => t.accountId === +mortgage.id).length
|
|
||||||
)
|
|
||||||
expect(mortgageAccount.holdings).toHaveLength(0)
|
|
||||||
expect(mortgageAccount.valuations).toHaveLength(0)
|
|
||||||
expect(mortgageAccount.investmentTransactions).toHaveLength(0)
|
|
||||||
|
|
||||||
// brokerage
|
|
||||||
const brokerageAccount = accounts.find((a) => a.finicityAccountId === brokerage.id)!
|
|
||||||
expect(brokerageAccount.transactions).toHaveLength(0)
|
|
||||||
expect(brokerageAccount.holdings).toHaveLength(brokerage.position!.length)
|
|
||||||
expect(brokerageAccount.valuations).toHaveLength(0)
|
|
||||||
expect(brokerageAccount.investmentTransactions).toHaveLength(
|
|
||||||
FinicityTestData.transactions.filter((t) => t.accountId === +brokerage.id).length
|
|
||||||
)
|
|
||||||
|
|
||||||
// credit
|
|
||||||
const creditAccount = accounts.find((a) => a.finicityAccountId === credit.id)!
|
|
||||||
expect(creditAccount.transactions).toHaveLength(
|
|
||||||
FinicityTestData.transactions.filter((t) => t.accountId === +credit.id).length
|
|
||||||
)
|
|
||||||
expect(creditAccount.holdings).toHaveLength(0)
|
|
||||||
expect(creditAccount.valuations).toHaveLength(0)
|
|
||||||
expect(creditAccount.investmentTransactions).toHaveLength(0)
|
|
||||||
|
|
||||||
// savings
|
|
||||||
const savingsAccount = accounts.find((a) => a.finicityAccountId === savings.id)!
|
|
||||||
expect(savingsAccount.transactions).toHaveLength(
|
|
||||||
FinicityTestData.transactions.filter((t) => t.accountId === +savings.id).length
|
|
||||||
)
|
|
||||||
expect(savingsAccount.holdings).toHaveLength(0)
|
|
||||||
expect(savingsAccount.valuations).toHaveLength(0)
|
|
||||||
expect(savingsAccount.investmentTransactions).toHaveLength(0)
|
|
||||||
|
|
||||||
// checking
|
|
||||||
const checkingAccount = accounts.find((a) => a.finicityAccountId === checking.id)!
|
|
||||||
expect(checkingAccount.transactions).toHaveLength(
|
|
||||||
FinicityTestData.transactions.filter((t) => t.accountId === +checking.id).length
|
|
||||||
)
|
|
||||||
expect(checkingAccount.holdings).toHaveLength(0)
|
|
||||||
expect(checkingAccount.valuations).toHaveLength(0)
|
|
||||||
expect(checkingAccount.investmentTransactions).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('syncs Betterment investment account', async () => {
|
|
||||||
finicity.getCustomerAccounts.mockResolvedValue({
|
|
||||||
accounts: [FinicityTestData.bettermentAccount],
|
|
||||||
})
|
|
||||||
|
|
||||||
finicity.getAccountTransactions.mockImplementation(({ accountId, start, limit }) => {
|
|
||||||
const transactions = FinicityTestData.bettermentTransactions.filter(
|
|
||||||
(t) => t.accountId === +accountId
|
|
||||||
)
|
|
||||||
|
|
||||||
const page = finicityPaginate(transactions, start, limit)
|
|
||||||
|
|
||||||
return Promise.resolve({
|
|
||||||
transactions: page,
|
|
||||||
found: transactions.length,
|
|
||||||
displaying: page.length,
|
|
||||||
moreAvailable: page.length < transactions.length ? 'true' : 'false',
|
|
||||||
fromDate: '1588939200',
|
|
||||||
toDate: '1651492800',
|
|
||||||
sort: 'desc',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const finicityETL = new FinicityETL(logger, prisma, finicity)
|
|
||||||
|
|
||||||
const connection = await prisma.accountConnection.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name: 'TEST[Betterment]',
|
|
||||||
type: 'finicity',
|
|
||||||
finicityInstitutionId: FinicityTestData.bettermentAccount.institutionId,
|
|
||||||
finicityInstitutionLoginId:
|
|
||||||
FinicityTestData.bettermentAccount.institutionLoginId.toString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await etl(finicityETL, connection)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('syncs investment transactions w/o securities', async () => {
|
|
||||||
finicity.getCustomerAccounts.mockResolvedValue({
|
|
||||||
accounts: [FinicityTestData.accounts.find((a) => a.type === 'investment')!],
|
|
||||||
})
|
|
||||||
|
|
||||||
finicity.getAccountTransactions.mockImplementation(({ accountId, start, limit }) => {
|
|
||||||
const transactions: FinicityTypes.Transaction[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
amount: 123,
|
|
||||||
accountId: +accountId,
|
|
||||||
customerId: 123,
|
|
||||||
status: 'active',
|
|
||||||
description: 'VANGUARD INST INDEX',
|
|
||||||
memo: 'Contributions',
|
|
||||||
type: 'Contributions',
|
|
||||||
unitQuantity: 8.283,
|
|
||||||
postedDate: 1674043200,
|
|
||||||
transactionDate: 1674043200,
|
|
||||||
createdDate: 1674707388,
|
|
||||||
tradeDate: 1674025200,
|
|
||||||
settlementDate: 1674043200,
|
|
||||||
investmentTransactionType: 'contribution',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
amount: -3.21,
|
|
||||||
accountId: +accountId,
|
|
||||||
customerId: 123,
|
|
||||||
status: 'active',
|
|
||||||
description: 'VANGUARD TARGET 2045',
|
|
||||||
memo: 'RECORDKEEPING FEE',
|
|
||||||
type: 'RECORDKEEPING FEE',
|
|
||||||
unitQuantity: 0.014,
|
|
||||||
postedDate: 1672747200,
|
|
||||||
transactionDate: 1672747200,
|
|
||||||
createdDate: 1674707388,
|
|
||||||
tradeDate: 1672729200,
|
|
||||||
settlementDate: 1672747200,
|
|
||||||
investmentTransactionType: 'fee',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
amount: -1.23,
|
|
||||||
accountId: +accountId,
|
|
||||||
customerId: 123,
|
|
||||||
status: 'active',
|
|
||||||
description: 'VANGUARD INST INDEX',
|
|
||||||
memo: 'Realized Gain/Loss',
|
|
||||||
type: 'Realized Gain/Loss',
|
|
||||||
unitQuantity: 0e-8,
|
|
||||||
postedDate: 1672747200,
|
|
||||||
transactionDate: 1672747200,
|
|
||||||
createdDate: 1674707388,
|
|
||||||
tradeDate: 1672729200,
|
|
||||||
settlementDate: 1672747200,
|
|
||||||
investmentTransactionType: 'other',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const page = finicityPaginate(transactions, start, limit)
|
|
||||||
|
|
||||||
return Promise.resolve({
|
|
||||||
transactions: page,
|
|
||||||
found: transactions.length,
|
|
||||||
displaying: page.length,
|
|
||||||
moreAvailable: page.length < transactions.length ? 'true' : 'false',
|
|
||||||
fromDate: '1588939200',
|
|
||||||
toDate: '1651492800',
|
|
||||||
sort: 'desc',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const finicityETL = new FinicityETL(logger, prisma, finicity)
|
|
||||||
|
|
||||||
const connection = await prisma.accountConnection.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name: 'TEST[Betterment]',
|
|
||||||
type: 'finicity',
|
|
||||||
finicityInstitutionId: FinicityTestData.bettermentAccount.institutionId,
|
|
||||||
finicityInstitutionLoginId:
|
|
||||||
FinicityTestData.bettermentAccount.institutionLoginId.toString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await etl(finicityETL, connection)
|
|
||||||
|
|
||||||
const accounts = await prisma.account.findMany({
|
|
||||||
where: {
|
|
||||||
accountConnectionId: connection.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
holdings: true,
|
|
||||||
transactions: true,
|
|
||||||
investmentTransactions: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(accounts).toHaveLength(1)
|
|
||||||
|
|
||||||
const account = accounts[0]
|
|
||||||
expect(account.holdings).toHaveLength(0)
|
|
||||||
expect(account.transactions).toHaveLength(0)
|
|
||||||
expect(account.investmentTransactions).toHaveLength(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This test is for debugging w/ real data locally
|
|
||||||
*/
|
|
||||||
it.skip('debug', async () => {
|
|
||||||
const data = (name: string) =>
|
|
||||||
JSON.parse(
|
|
||||||
fs.readFileSync(`${process.env.NX_TEST_DATA_FOLDER}/finicity/${name}.json`, 'utf-8')
|
|
||||||
)
|
|
||||||
|
|
||||||
finicity.getCustomerAccounts.mockResolvedValue(data('accounts'))
|
|
||||||
finicity.getAccountTransactions.mockResolvedValue(data('transactions'))
|
|
||||||
|
|
||||||
const finicityETL = new FinicityETL(logger, prisma, finicity)
|
|
||||||
|
|
||||||
const connection = await prisma.accountConnection.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name: 'TEST[DEBUG]',
|
|
||||||
type: 'finicity',
|
|
||||||
finicityInstitutionId: '123',
|
|
||||||
finicityInstitutionLoginId: '123',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await etl(finicityETL, connection)
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -15,7 +15,6 @@ export async function resetUser(prisma: PrismaClient, authId = '__TEST_USER_ID__
|
||||||
data: {
|
data: {
|
||||||
authId,
|
authId,
|
||||||
email: faker.internet.email(),
|
email: faker.internet.email(),
|
||||||
finicityCustomerId: faker.string.uuid(),
|
|
||||||
tellerUserId: faker.string.uuid(),
|
tellerUserId: faker.string.uuid(),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -1,26 +1,21 @@
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// Keep these imports above the rest to avoid errors
|
// Keep these imports above the rest to avoid errors
|
||||||
// =====================================================
|
// =====================================================
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
import { TellerGenerator } from 'tools/generators'
|
||||||
import type { AccountsGetResponse, TransactionsGetResponse } from 'plaid'
|
import type { User, AccountConnection } from '@prisma/client'
|
||||||
import type { AccountConnection, User } from '@prisma/client'
|
import { AccountConnectionType } from '@prisma/client'
|
||||||
import { TestUtil } from '@maybe-finance/shared'
|
|
||||||
import { PlaidTestData } from '../../../../../tools/test-data'
|
|
||||||
import { Prisma } from '@prisma/client'
|
|
||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
import { default as _plaid } from '../lib/plaid'
|
import { default as _teller } from '../lib/teller'
|
||||||
import nock from 'nock'
|
|
||||||
import { DateTime } from 'luxon'
|
|
||||||
import { resetUser } from './helpers/user.test-helper'
|
import { resetUser } from './helpers/user.test-helper'
|
||||||
|
import { Interval } from 'luxon'
|
||||||
|
|
||||||
// Import the workers process
|
// Import the workers process
|
||||||
import '../../main'
|
import '../../main'
|
||||||
import { queueService, securityPricingService } from '../lib/di'
|
import { queueService } from '../lib/di'
|
||||||
|
|
||||||
jest.mock('plaid')
|
|
||||||
|
|
||||||
// For TypeScript support
|
// For TypeScript support
|
||||||
const plaid = jest.mocked(_plaid)
|
jest.mock('../lib/teller')
|
||||||
|
const teller = jest.mocked(_teller)
|
||||||
|
|
||||||
let user: User | null
|
let user: User | null
|
||||||
let connection: AccountConnection
|
let connection: AccountConnection
|
||||||
|
@ -30,25 +25,6 @@ if (process.env.IS_VSCODE_DEBUG === 'true') {
|
||||||
jest.setTimeout(100000)
|
jest.setTimeout(100000)
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
nock.disableNetConnect()
|
|
||||||
|
|
||||||
nock('https://api.polygon.io')
|
|
||||||
.get((uri) => uri.includes('v2/aggs/ticker/AAPL/range/1/day'))
|
|
||||||
.reply(200, PlaidTestData.AAPL)
|
|
||||||
.persist()
|
|
||||||
|
|
||||||
nock('https://api.polygon.io')
|
|
||||||
.get((uri) => uri.includes('v2/aggs/ticker/WMT/range/1/day'))
|
|
||||||
.reply(200, PlaidTestData.WMT)
|
|
||||||
.persist()
|
|
||||||
|
|
||||||
nock('https://api.polygon.io')
|
|
||||||
.get((uri) => uri.includes('v2/aggs/ticker/VOO/range/1/day'))
|
|
||||||
.reply(200, PlaidTestData.VOO)
|
|
||||||
.persist()
|
|
||||||
})
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
|
|
||||||
|
@ -57,10 +33,10 @@ beforeEach(async () => {
|
||||||
connection = await prisma.accountConnection.create({
|
connection = await prisma.accountConnection.create({
|
||||||
data: {
|
data: {
|
||||||
name: 'Chase Test',
|
name: 'Chase Test',
|
||||||
type: 'plaid' as SharedType.AccountConnectionType,
|
type: AccountConnectionType.teller,
|
||||||
plaidItemId: 'test-plaid-item-workers',
|
tellerEnrollmentId: 'test-teller-item-workers',
|
||||||
plaidInstitutionId: 'ins_3',
|
tellerInstitutionId: 'chase_test',
|
||||||
plaidAccessToken:
|
tellerAccessToken:
|
||||||
'U2FsdGVkX1+WMq9lfTS9Zkbgrn41+XT1hvSK5ain/udRPujzjVCAx/lyPG7EumVZA+nVKXPauGwI+d7GZgtqTA9R3iCZNusU6LFPnmFOCE4=', // need correct encoding here
|
'U2FsdGVkX1+WMq9lfTS9Zkbgrn41+XT1hvSK5ain/udRPujzjVCAx/lyPG7EumVZA+nVKXPauGwI+d7GZgtqTA9R3iCZNusU6LFPnmFOCE4=', // need correct encoding here
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
syncStatus: 'PENDING',
|
syncStatus: 'PENDING',
|
||||||
|
@ -84,7 +60,7 @@ describe('Message queue tests', () => {
|
||||||
it('Should handle sync errors', async () => {
|
it('Should handle sync errors', async () => {
|
||||||
const syncQueue = queueService.getQueue('sync-account-connection')
|
const syncQueue = queueService.getQueue('sync-account-connection')
|
||||||
|
|
||||||
plaid.accountsGet.mockRejectedValueOnce('forced error for Jest tests')
|
teller.getAccounts.mockRejectedValueOnce(new Error('forced error for Jest tests'))
|
||||||
|
|
||||||
await syncQueue.add('sync-connection', { accountConnectionId: connection.id })
|
await syncQueue.add('sync-connection', { accountConnectionId: connection.id })
|
||||||
|
|
||||||
|
@ -92,7 +68,7 @@ describe('Message queue tests', () => {
|
||||||
where: { id: connection.id },
|
where: { id: connection.id },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(plaid.accountsGet).toHaveBeenCalledTimes(1)
|
expect(teller.getAccounts).toHaveBeenCalledTimes(1)
|
||||||
expect(updatedConnection?.status).toEqual('ERROR')
|
expect(updatedConnection?.status).toEqual('ERROR')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -117,28 +93,23 @@ describe('Message queue tests', () => {
|
||||||
const syncQueue = queueService.getQueue('sync-account-connection')
|
const syncQueue = queueService.getQueue('sync-account-connection')
|
||||||
|
|
||||||
// Mock will return a basic banking checking account
|
// Mock will return a basic banking checking account
|
||||||
plaid.accountsGet.mockResolvedValueOnce(
|
const mockAccounts = TellerGenerator.generateAccountsWithBalances({
|
||||||
TestUtil.axiosSuccess<AccountsGetResponse>({
|
count: 1,
|
||||||
accounts: [PlaidTestData.checkingAccount],
|
institutionId: 'chase_test',
|
||||||
item: PlaidTestData.item,
|
enrollmentId: 'test-teller-item-workers',
|
||||||
request_id: 'bkVE1BHWMAZ9Rnr',
|
institutionName: 'Chase Test',
|
||||||
}) as any
|
accountType: 'depository',
|
||||||
)
|
accountSubType: 'checking',
|
||||||
|
})
|
||||||
|
teller.getAccounts.mockResolvedValueOnce(mockAccounts)
|
||||||
|
|
||||||
plaid.transactionsGet.mockResolvedValueOnce(
|
const mockTransactions = TellerGenerator.generateTransactions(10, mockAccounts[0].id)
|
||||||
TestUtil.axiosSuccess<TransactionsGetResponse>({
|
teller.getTransactions.mockResolvedValueOnce(mockTransactions)
|
||||||
accounts: [PlaidTestData.checkingAccount],
|
|
||||||
transactions: PlaidTestData.checkingTransactions,
|
|
||||||
item: PlaidTestData.item,
|
|
||||||
total_transactions: PlaidTestData.checkingTransactions.length,
|
|
||||||
request_id: '45QSn',
|
|
||||||
}) as any
|
|
||||||
)
|
|
||||||
|
|
||||||
await syncQueue.add('sync-connection', { accountConnectionId: connection.id })
|
await syncQueue.add('sync-connection', { accountConnectionId: connection.id })
|
||||||
|
|
||||||
expect(plaid.accountsGet).toHaveBeenCalledTimes(1)
|
expect(teller.getAccounts).toHaveBeenCalledTimes(1)
|
||||||
expect(plaid.transactionsGet).toHaveBeenCalledTimes(1)
|
expect(teller.getTransactions).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
const item = await prisma.accountConnection.findUniqueOrThrow({
|
const item = await prisma.accountConnection.findUniqueOrThrow({
|
||||||
where: { id: connection.id },
|
where: { id: connection.id },
|
||||||
|
@ -146,7 +117,7 @@ describe('Message queue tests', () => {
|
||||||
accounts: {
|
accounts: {
|
||||||
include: {
|
include: {
|
||||||
balances: {
|
balances: {
|
||||||
where: PlaidTestData.testDates.prismaWhereFilter,
|
where: TellerGenerator.testDates.prismaWhereFilter,
|
||||||
orderBy: { date: 'asc' },
|
orderBy: { date: 'asc' },
|
||||||
},
|
},
|
||||||
transactions: true,
|
transactions: true,
|
||||||
|
@ -162,61 +133,25 @@ describe('Message queue tests', () => {
|
||||||
|
|
||||||
const [account] = item.accounts
|
const [account] = item.accounts
|
||||||
|
|
||||||
expect(account.transactions).toHaveLength(PlaidTestData.checkingTransactions.length)
|
const intervalDates = Interval.fromDateTimes(
|
||||||
expect(account.balances.map((b) => b.balance)).toEqual(
|
TellerGenerator.lowerBound,
|
||||||
[
|
TellerGenerator.now
|
||||||
3630,
|
|
||||||
5125,
|
|
||||||
5125,
|
|
||||||
5125,
|
|
||||||
5125,
|
|
||||||
5125,
|
|
||||||
5125,
|
|
||||||
5125,
|
|
||||||
5125,
|
|
||||||
5125,
|
|
||||||
5115,
|
|
||||||
5115,
|
|
||||||
5115,
|
|
||||||
5089.45,
|
|
||||||
5089.45,
|
|
||||||
PlaidTestData.checkingAccount.balances.current!,
|
|
||||||
].map((v) => new Prisma.Decimal(v))
|
|
||||||
)
|
)
|
||||||
|
.splitBy({ day: 1 })
|
||||||
|
.map((date: Interval) => date.start.toISODate())
|
||||||
|
|
||||||
|
const startingBalance = Number(mockAccounts[0].balance.available)
|
||||||
|
|
||||||
|
const balances = TellerGenerator.calculateDailyBalances(
|
||||||
|
startingBalance,
|
||||||
|
mockTransactions,
|
||||||
|
intervalDates
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(account.transactions).toHaveLength(10)
|
||||||
|
expect(account.balances.map((b) => b.balance)).toEqual(balances)
|
||||||
expect(account.holdings).toHaveLength(0)
|
expect(account.holdings).toHaveLength(0)
|
||||||
expect(account.valuations).toHaveLength(0)
|
expect(account.valuations).toHaveLength(0)
|
||||||
expect(account.investmentTransactions).toHaveLength(0)
|
expect(account.investmentTransactions).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should sync valid security prices', async () => {
|
|
||||||
const security = await prisma.security.create({
|
|
||||||
data: {
|
|
||||||
name: 'Walmart Inc.',
|
|
||||||
symbol: 'WMT',
|
|
||||||
cusip: '93114210310',
|
|
||||||
pricingLastSyncedAt: new Date(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await securityPricingService.sync(security)
|
|
||||||
|
|
||||||
const prices = await prisma.securityPricing.findMany({
|
|
||||||
where: { securityId: security.id },
|
|
||||||
orderBy: { date: 'asc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(prices).toHaveLength(PlaidTestData.WMT.results.length)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
prices.map((p) => ({
|
|
||||||
date: DateTime.fromJSDate(p.date, { zone: 'utc' }).toISODate(),
|
|
||||||
price: p.priceClose.toNumber(),
|
|
||||||
}))
|
|
||||||
).toEqual(
|
|
||||||
PlaidTestData.WMT.results.map((p) => ({
|
|
||||||
date: DateTime.fromMillis(p.t, { zone: 'utc' }).toISODate(),
|
|
||||||
price: p.c,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
@ -20,8 +20,6 @@ import {
|
||||||
AccountQueryService,
|
AccountQueryService,
|
||||||
AccountService,
|
AccountService,
|
||||||
BalanceSyncStrategyFactory,
|
BalanceSyncStrategyFactory,
|
||||||
FinicityETL,
|
|
||||||
FinicityService,
|
|
||||||
InstitutionProviderFactory,
|
InstitutionProviderFactory,
|
||||||
InstitutionService,
|
InstitutionService,
|
||||||
InvestmentTransactionBalanceSyncStrategy,
|
InvestmentTransactionBalanceSyncStrategy,
|
||||||
|
@ -56,7 +54,6 @@ import Redis from 'ioredis'
|
||||||
import logger from './logger'
|
import logger from './logger'
|
||||||
import prisma from './prisma'
|
import prisma from './prisma'
|
||||||
import plaid from './plaid'
|
import plaid from './plaid'
|
||||||
import finicity from './finicity'
|
|
||||||
import teller from './teller'
|
import teller from './teller'
|
||||||
import postmark from './postmark'
|
import postmark from './postmark'
|
||||||
import stripe from './stripe'
|
import stripe from './stripe'
|
||||||
|
@ -118,15 +115,6 @@ const plaidService = new PlaidService(
|
||||||
''
|
''
|
||||||
)
|
)
|
||||||
|
|
||||||
const finicityService = new FinicityService(
|
|
||||||
logger.child({ service: 'FinicityService' }),
|
|
||||||
prisma,
|
|
||||||
finicity,
|
|
||||||
new FinicityETL(logger.child({ service: 'FinicityETL' }), prisma, finicity),
|
|
||||||
'',
|
|
||||||
env.NX_FINICITY_ENV === 'sandbox'
|
|
||||||
)
|
|
||||||
|
|
||||||
const tellerService = new TellerService(
|
const tellerService = new TellerService(
|
||||||
logger.child({ service: 'TellerService' }),
|
logger.child({ service: 'TellerService' }),
|
||||||
prisma,
|
prisma,
|
||||||
|
@ -141,7 +129,6 @@ const tellerService = new TellerService(
|
||||||
|
|
||||||
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
|
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
|
||||||
plaid: plaidService,
|
plaid: plaidService,
|
||||||
finicity: finicityService,
|
|
||||||
teller: tellerService,
|
teller: tellerService,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -258,7 +245,6 @@ export const securityPricingProcessor: ISecurityPricingProcessor = new SecurityP
|
||||||
|
|
||||||
const institutionProviderFactory = new InstitutionProviderFactory({
|
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||||
PLAID: plaidService,
|
PLAID: plaidService,
|
||||||
FINICITY: finicityService,
|
|
||||||
TELLER: tellerService,
|
TELLER: tellerService,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
import { FinicityApi } from '@maybe-finance/finicity-api'
|
|
||||||
import env from '../../env'
|
|
||||||
|
|
||||||
const finicity = new FinicityApi(
|
|
||||||
env.NX_FINICITY_APP_KEY,
|
|
||||||
env.NX_FINICITY_PARTNER_ID,
|
|
||||||
env.NX_FINICITY_PARTNER_SECRET
|
|
||||||
)
|
|
||||||
|
|
||||||
export default finicity
|
|
|
@ -10,11 +10,6 @@ const envSchema = z.object({
|
||||||
NX_PLAID_CLIENT_ID: z.string().default('REPLACE_THIS'),
|
NX_PLAID_CLIENT_ID: z.string().default('REPLACE_THIS'),
|
||||||
NX_PLAID_SECRET: z.string(),
|
NX_PLAID_SECRET: z.string(),
|
||||||
|
|
||||||
NX_FINICITY_APP_KEY: z.string(),
|
|
||||||
NX_FINICITY_PARTNER_ID: z.string().default('REPLACE_THIS'),
|
|
||||||
NX_FINICITY_PARTNER_SECRET: z.string(),
|
|
||||||
NX_FINICITY_ENV: z.string().default('sandbox'),
|
|
||||||
|
|
||||||
NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'),
|
NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'),
|
||||||
NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'),
|
NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'),
|
||||||
NX_TELLER_ENV: z.string().default('sandbox'),
|
NX_TELLER_ENV: z.string().default('sandbox'),
|
||||||
|
|
|
@ -112,11 +112,6 @@ syncInstitutionQueue.process(
|
||||||
async () => await institutionService.sync('PLAID')
|
async () => await institutionService.sync('PLAID')
|
||||||
)
|
)
|
||||||
|
|
||||||
syncInstitutionQueue.process(
|
|
||||||
'sync-finicity-institutions',
|
|
||||||
async () => await institutionService.sync('FINICITY')
|
|
||||||
)
|
|
||||||
|
|
||||||
syncInstitutionQueue.process(
|
syncInstitutionQueue.process(
|
||||||
'sync-teller-institutions',
|
'sync-teller-institutions',
|
||||||
async () => await institutionService.sync('TELLER')
|
async () => await institutionService.sync('TELLER')
|
||||||
|
@ -131,15 +126,6 @@ syncInstitutionQueue.add(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
syncInstitutionQueue.add(
|
|
||||||
'sync-finicity-institutions',
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
|
|
||||||
jobId: Date.now().toString(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
syncInstitutionQueue.add(
|
syncInstitutionQueue.add(
|
||||||
'sync-teller-institutions',
|
'sync-teller-institutions',
|
||||||
{},
|
{},
|
||||||
|
|
|
@ -1,36 +0,0 @@
|
||||||
import {
|
|
||||||
useAccountConnectionApi,
|
|
||||||
useAccountContext,
|
|
||||||
useFinicity,
|
|
||||||
} from '@maybe-finance/client/shared'
|
|
||||||
import { Button } from '@maybe-finance/design-system'
|
|
||||||
|
|
||||||
type FinicityFixConnectButtonProps = {
|
|
||||||
accountConnectionId: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FinicityFixConnectButton({
|
|
||||||
accountConnectionId,
|
|
||||||
}: FinicityFixConnectButtonProps) {
|
|
||||||
const { launch } = useFinicity()
|
|
||||||
const { setAccountManager } = useAccountContext()
|
|
||||||
|
|
||||||
const { useCreateFinicityFixConnectUrl } = useAccountConnectionApi()
|
|
||||||
|
|
||||||
const createFixConnectUrl = useCreateFinicityFixConnectUrl({
|
|
||||||
onSuccess({ link }) {
|
|
||||||
launch(link)
|
|
||||||
setAccountManager({ view: 'idle' })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => createFixConnectUrl.mutate(accountConnectionId)}
|
|
||||||
disabled={createFixConnectUrl.isLoading}
|
|
||||||
>
|
|
||||||
Fix connection
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -6,7 +6,6 @@ import {
|
||||||
useAccountContext,
|
useAccountContext,
|
||||||
useDebounce,
|
useDebounce,
|
||||||
usePlaid,
|
usePlaid,
|
||||||
useFinicity,
|
|
||||||
useTellerConfig,
|
useTellerConfig,
|
||||||
useTellerConnect,
|
useTellerConnect,
|
||||||
} from '@maybe-finance/client/shared'
|
} from '@maybe-finance/client/shared'
|
||||||
|
@ -39,7 +38,6 @@ export default function AccountTypeSelector({
|
||||||
const config = useTellerConfig(logger)
|
const config = useTellerConfig(logger)
|
||||||
|
|
||||||
const { openPlaid } = usePlaid()
|
const { openPlaid } = usePlaid()
|
||||||
const { openFinicity } = useFinicity()
|
|
||||||
const { open: openTeller } = useTellerConnect(config, logger)
|
const { open: openTeller } = useTellerConnect(config, logger)
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
@ -80,9 +78,6 @@ export default function AccountTypeSelector({
|
||||||
case 'PLAID':
|
case 'PLAID':
|
||||||
openPlaid(providerInstitution.providerId)
|
openPlaid(providerInstitution.providerId)
|
||||||
break
|
break
|
||||||
case 'FINICITY':
|
|
||||||
openFinicity(providerInstitution.providerId)
|
|
||||||
break
|
|
||||||
case 'TELLER':
|
case 'TELLER':
|
||||||
openTeller(providerInstitution.providerId)
|
openTeller(providerInstitution.providerId)
|
||||||
break
|
break
|
||||||
|
@ -158,9 +153,6 @@ export default function AccountTypeSelector({
|
||||||
case 'PLAID':
|
case 'PLAID':
|
||||||
openPlaid(data.providerId)
|
openPlaid(data.providerId)
|
||||||
break
|
break
|
||||||
case 'FINICITY':
|
|
||||||
openFinicity(data.providerId)
|
|
||||||
break
|
|
||||||
case 'TELLER':
|
case 'TELLER':
|
||||||
openTeller(data.providerId)
|
openTeller(data.providerId)
|
||||||
break
|
break
|
||||||
|
|
|
@ -72,18 +72,10 @@ const brokerages: GridImage[] = [
|
||||||
{
|
{
|
||||||
src: 'fidelity.png',
|
src: 'fidelity.png',
|
||||||
alt: 'Fidelity',
|
alt: 'Fidelity',
|
||||||
institution: {
|
|
||||||
provider: 'FINICITY',
|
|
||||||
providerId: '9913',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'vanguard.png',
|
src: 'vanguard.png',
|
||||||
alt: 'Vanguard',
|
alt: 'Vanguard',
|
||||||
institution: {
|
|
||||||
provider: 'FINICITY',
|
|
||||||
providerId: '3078',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'wealthfront.png',
|
src: 'wealthfront.png',
|
||||||
|
|
|
@ -16,7 +16,6 @@ import {
|
||||||
RiFolderOpenLine,
|
RiFolderOpenLine,
|
||||||
RiMenuFoldLine,
|
RiMenuFoldLine,
|
||||||
RiMenuUnfoldLine,
|
RiMenuUnfoldLine,
|
||||||
RiMore2Fill,
|
|
||||||
RiPieChart2Line,
|
RiPieChart2Line,
|
||||||
RiFlagLine,
|
RiFlagLine,
|
||||||
RiArrowRightSLine,
|
RiArrowRightSLine,
|
||||||
|
@ -191,9 +190,11 @@ export function DesktopLayout({ children, sidebar }: DesktopLayoutProps) {
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Link href="/settings">
|
<MenuPopover
|
||||||
<ProfileCircle />
|
isHeader={false}
|
||||||
</Link>
|
icon={<ProfileCircle />}
|
||||||
|
placement={'right-end'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
@ -344,7 +345,6 @@ function DefaultContent({
|
||||||
<p data-testid="user-name">{name ?? ''}</p>
|
<p data-testid="user-name">{name ?? ''}</p>
|
||||||
<p className="text-gray-100">{email ?? ''}</p>
|
<p className="text-gray-100">{email ?? ''}</p>
|
||||||
</div>
|
</div>
|
||||||
<MenuPopover isHeader={false} icon={<RiMore2Fill />} />
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -18,7 +18,7 @@ export function MenuPopover({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<Menu.Button variant="icon">{icon}</Menu.Button>
|
<Menu.Button variant="profileIcon">{icon}</Menu.Button>
|
||||||
<Menu.Items
|
<Menu.Items
|
||||||
placement={placement}
|
placement={placement}
|
||||||
className={isHeader ? 'bg-gray-600' : 'min-w-[200px]'}
|
className={isHeader ? 'bg-gray-600' : 'min-w-[200px]'}
|
||||||
|
|
|
@ -13,7 +13,7 @@ export function Intro({ onNext, title }: StepProps) {
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Wave />
|
<Wave />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-6">{title}</h2>
|
<h2 className="mt-6 text-pretty">{title}</h2>
|
||||||
<p className="mt-2 text-base text-gray-50">
|
<p className="mt-2 text-base text-gray-50">
|
||||||
We’re super excited you’re here. In the next step we’ll ask
|
We’re super excited you’re here. In the next step we’ll ask
|
||||||
you a few questions to complete your profile and get you all set up.
|
you a few questions to complete your profile and get you all set up.
|
||||||
|
|
|
@ -116,7 +116,7 @@ function ProfileForm({ title, onSubmit, defaultValues }: ProfileViewProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-md mx-auto">
|
<div className="w-full max-w-md mx-auto">
|
||||||
<h3 className="text-center">{title}</h3>
|
<h3 className="text-center text-pretty">{title}</h3>
|
||||||
<p className="mt-4 text-base text-gray-50">
|
<p className="mt-4 text-base text-gray-50">
|
||||||
We’ll need a few things from you to offer a personal experience when it comes
|
We’ll need a few things from you to offer a personal experience when it comes
|
||||||
to building plans or receiving advice from our advisors.
|
to building plans or receiving advice from our advisors.
|
||||||
|
|
|
@ -55,7 +55,7 @@ export function Welcome({ title: stepTitle, onNext }: StepProps) {
|
||||||
>
|
>
|
||||||
<div className="max-w-md grow">
|
<div className="max-w-md grow">
|
||||||
<img src="/assets/maybe.svg" className="h-8" alt="Maybe" />
|
<img src="/assets/maybe.svg" className="h-8" alt="Maybe" />
|
||||||
<h3 className="mt-14">{stepTitle}</h3>
|
<h3 className="mt-14 text-pretty">{stepTitle}</h3>
|
||||||
<p className="mt-2 text-base text-gray-50">
|
<p className="mt-2 text-base text-gray-50">
|
||||||
We made you a little something to celebrate you taking your first steps in
|
We made you a little something to celebrate you taking your first steps in
|
||||||
Maybe. Feel free to share and don’t forget to flip the card!
|
Maybe. Feel free to share and don’t forget to flip the card!
|
||||||
|
|
|
@ -130,7 +130,7 @@ export function YourMaybe({ title, onNext }: StepProps) {
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Fire />
|
<Fire />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-6 text-center">{title}</h2>
|
<h2 className="mt-6 text-center text-pretty">{title}</h2>
|
||||||
<p className="mt-2 text-center text-base text-gray-50">
|
<p className="mt-2 text-center text-base text-gray-50">
|
||||||
A maybe is a goal or dream you’re considering, but have not yet fully
|
A maybe is a goal or dream you’re considering, but have not yet fully
|
||||||
committed to because you’re not yet sure if it’s financially
|
committed to because you’re not yet sure if it’s financially
|
||||||
|
|
|
@ -31,7 +31,7 @@ export function AddFirstAccount({ title, onNext }: StepProps) {
|
||||||
unmount={false}
|
unmount={false}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<h3>{title}</h3>
|
<h3 className="text-pretty">{title}</h3>
|
||||||
<div className="text-base text-gray-50">
|
<div className="text-base text-gray-50">
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
To get the most out of Maybe you need to add your financial
|
To get the most out of Maybe you need to add your financial
|
||||||
|
|
|
@ -77,7 +77,7 @@ export function EmailVerification({ title, onNext }: StepProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-12 text-center">
|
<h3 className="mt-12 text-center text-pretty">
|
||||||
{profile.data?.emailVerified ? 'Email verified' : title}
|
{profile.data?.emailVerified ? 'Email verified' : title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="text-base text-center">
|
<div className="text-base text-center">
|
||||||
|
|
|
@ -105,7 +105,7 @@ export function OtherAccounts({ title, onNext }: StepProps) {
|
||||||
<div className="min-h-[700px] overflow-x-hidden">
|
<div className="min-h-[700px] overflow-x-hidden">
|
||||||
<div className="flex max-w-5xl mx-auto gap-32 justify-center sm:justify-start">
|
<div className="flex max-w-5xl mx-auto gap-32 justify-center sm:justify-start">
|
||||||
<div className="grow max-w-md sm:mt-12 text-center sm:text-start">
|
<div className="grow max-w-md sm:mt-12 text-center sm:text-start">
|
||||||
<h3>{title}</h3>
|
<h3 className="text-pretty">{title}</h3>
|
||||||
<p className="mt-2 text-base text-gray-50">
|
<p className="mt-2 text-base text-gray-50">
|
||||||
You can select bank accounts if there are any other accounts you’d
|
You can select bank accounts if there are any other accounts you’d
|
||||||
like to add. Feel free to select more than one. We’ll add these to
|
like to add. Feel free to select more than one. We’ll add these to
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
export * from './useAccountApi'
|
export * from './useAccountApi'
|
||||||
export * from './useAccountConnectionApi'
|
export * from './useAccountConnectionApi'
|
||||||
export * from './useAuthUserApi'
|
export * from './useAuthUserApi'
|
||||||
export * from './useFinicityApi'
|
|
||||||
export * from './useInstitutionApi'
|
export * from './useInstitutionApi'
|
||||||
export * from './useUserApi'
|
export * from './useUserApi'
|
||||||
export * from './usePlaidApi'
|
export * from './usePlaidApi'
|
||||||
|
|
|
@ -33,13 +33,6 @@ const AccountConnectionApi = (axios: AxiosInstance) => ({
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
async createFinicityFixConnectUrl(id: SharedType.AccountConnection['id']) {
|
|
||||||
const { data } = await axios.post<{ link: string }>(
|
|
||||||
`/connections/${id}/finicity/fix-connect`
|
|
||||||
)
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
async disconnect(id: SharedType.AccountConnection['id']) {
|
async disconnect(id: SharedType.AccountConnection['id']) {
|
||||||
const { data } = await axios.post<SharedType.AccountConnection>(
|
const { data } = await axios.post<SharedType.AccountConnection>(
|
||||||
`/connections/${id}/disconnect`
|
`/connections/${id}/disconnect`
|
||||||
|
@ -119,10 +112,6 @@ export function useAccountConnectionApi() {
|
||||||
const useCreatePlaidLinkToken = (mode: SharedType.PlaidLinkUpdateMode) =>
|
const useCreatePlaidLinkToken = (mode: SharedType.PlaidLinkUpdateMode) =>
|
||||||
useMutation((id: SharedType.AccountConnection['id']) => api.createPlaidLinkToken(id, mode))
|
useMutation((id: SharedType.AccountConnection['id']) => api.createPlaidLinkToken(id, mode))
|
||||||
|
|
||||||
const useCreateFinicityFixConnectUrl = (
|
|
||||||
options?: UseMutationOptions<{ link: string }, unknown, number, unknown>
|
|
||||||
) => useMutation(api.createFinicityFixConnectUrl, options)
|
|
||||||
|
|
||||||
const useDisconnectConnection = () =>
|
const useDisconnectConnection = () =>
|
||||||
useMutation(api.disconnect, {
|
useMutation(api.disconnect, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
@ -188,7 +177,6 @@ export function useAccountConnectionApi() {
|
||||||
useDeleteConnection,
|
useDeleteConnection,
|
||||||
useDeleteAllConnections,
|
useDeleteAllConnections,
|
||||||
useCreatePlaidLinkToken,
|
useCreatePlaidLinkToken,
|
||||||
useCreateFinicityFixConnectUrl,
|
|
||||||
useReconnectConnection,
|
useReconnectConnection,
|
||||||
useDisconnectConnection,
|
useDisconnectConnection,
|
||||||
useSyncConnection,
|
useSyncConnection,
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
import { useMemo } from 'react'
|
|
||||||
import type { AxiosInstance } from 'axios'
|
|
||||||
import { useMutation } from '@tanstack/react-query'
|
|
||||||
import { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'
|
|
||||||
|
|
||||||
const FinicityApi = (axios: AxiosInstance) => ({
|
|
||||||
async generateConnectUrl(institutionId: string) {
|
|
||||||
const { data } = await axios.post<{ link: string }>('/finicity/connect-url', {
|
|
||||||
institutionId,
|
|
||||||
})
|
|
||||||
return data.link
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export function useFinicityApi() {
|
|
||||||
const { axios } = useAxiosWithAuth()
|
|
||||||
const api = useMemo(() => FinicityApi(axios), [axios])
|
|
||||||
|
|
||||||
const useGenerateConnectUrl = () => useMutation(api.generateConnectUrl)
|
|
||||||
|
|
||||||
return { useGenerateConnectUrl }
|
|
||||||
}
|
|
|
@ -1,6 +1,5 @@
|
||||||
export * from './useAxiosWithAuth'
|
export * from './useAxiosWithAuth'
|
||||||
export * from './useDebounce'
|
export * from './useDebounce'
|
||||||
export * from './useFinicity'
|
|
||||||
export * from './useInterval'
|
export * from './useInterval'
|
||||||
export * from './useLastUpdated'
|
export * from './useLastUpdated'
|
||||||
export * from './useLocalStorage'
|
export * from './useLocalStorage'
|
||||||
|
|
|
@ -1,70 +0,0 @@
|
||||||
import { useCallback } from 'react'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
import * as Sentry from '@sentry/react'
|
|
||||||
import type {
|
|
||||||
ConnectCancelEvent,
|
|
||||||
ConnectDoneEvent,
|
|
||||||
ConnectErrorEvent,
|
|
||||||
} from '@finicity/connect-web-sdk'
|
|
||||||
import { useFinicityApi } from '../api'
|
|
||||||
import { useAccountContext, useUserAccountContext } from '../providers'
|
|
||||||
import { useLogger } from './useLogger'
|
|
||||||
|
|
||||||
export function useFinicity() {
|
|
||||||
const logger = useLogger()
|
|
||||||
|
|
||||||
const { useGenerateConnectUrl } = useFinicityApi()
|
|
||||||
const generateConnectUrl = useGenerateConnectUrl()
|
|
||||||
|
|
||||||
const { setExpectingAccounts } = useUserAccountContext()
|
|
||||||
const { setAccountManager } = useAccountContext()
|
|
||||||
|
|
||||||
const launch = useCallback(
|
|
||||||
async (linkOrPromise: string | Promise<string>) => {
|
|
||||||
const toastId = toast.loading('Initializing Finicity...', { duration: 10_000 })
|
|
||||||
|
|
||||||
const [{ FinicityConnect }, link] = await Promise.all([
|
|
||||||
import('@finicity/connect-web-sdk'),
|
|
||||||
linkOrPromise,
|
|
||||||
])
|
|
||||||
|
|
||||||
toast.dismiss(toastId)
|
|
||||||
|
|
||||||
FinicityConnect.launch(link, {
|
|
||||||
onDone(evt: ConnectDoneEvent) {
|
|
||||||
logger.debug(`Finicity Connect onDone event`, evt)
|
|
||||||
setExpectingAccounts(true)
|
|
||||||
},
|
|
||||||
onError(evt: ConnectErrorEvent) {
|
|
||||||
logger.error(`Finicity Connect exited with error`, evt)
|
|
||||||
Sentry.captureEvent({
|
|
||||||
level: 'error',
|
|
||||||
message: 'FINICITY_CONNECT_ERROR',
|
|
||||||
tags: {
|
|
||||||
'finicity.error.code': evt.code,
|
|
||||||
'finicity.error.reason': evt.reason,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onCancel(evt: ConnectCancelEvent) {
|
|
||||||
logger.debug(`Finicity Connect onCancel event`, evt)
|
|
||||||
},
|
|
||||||
onUser() {
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
onRoute() {
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[logger, setExpectingAccounts]
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
launch,
|
|
||||||
openFinicity: async (institutionId: string) => {
|
|
||||||
launch(generateConnectUrl.mutateAsync(institutionId))
|
|
||||||
setAccountManager({ view: 'idle' })
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -48,7 +48,6 @@ export type UpdateLiabilityFields = CreateLiabilityFields
|
||||||
type AccountManager =
|
type AccountManager =
|
||||||
| { view: 'idle' }
|
| { view: 'idle' }
|
||||||
| { view: 'add-plaid'; linkToken: string }
|
| { view: 'add-plaid'; linkToken: string }
|
||||||
| { view: 'add-finicity' }
|
|
||||||
| { view: 'add-teller' }
|
| { view: 'add-teller' }
|
||||||
| { view: 'add-account' }
|
| { view: 'add-account' }
|
||||||
| { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> }
|
| { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> }
|
||||||
|
|
|
@ -10,6 +10,7 @@ const ButtonVariants = Object.freeze({
|
||||||
input: 'px-4 py-2 rounded text-base bg-transparent text-gray-25 border border-gray-200 shadow focus:bg-gray-500 focus:ring-gray-400',
|
input: 'px-4 py-2 rounded text-base bg-transparent text-gray-25 border border-gray-200 shadow focus:bg-gray-500 focus:ring-gray-400',
|
||||||
link: 'px-4 py-2 rounded text-base text-cyan hover:text-cyan-400 focus:text-cyan-300 focus:ring-cyan',
|
link: 'px-4 py-2 rounded text-base text-cyan hover:text-cyan-400 focus:text-cyan-300 focus:ring-cyan',
|
||||||
icon: 'p-0 w-8 h-8 rounded text-2xl text-gray-25 hover:bg-gray-300 focus:bg-gray-200 focus:ring-gray-400',
|
icon: 'p-0 w-8 h-8 rounded text-2xl text-gray-25 hover:bg-gray-300 focus:bg-gray-200 focus:ring-gray-400',
|
||||||
|
profileIcon: 'p-0 w-12 h-12 rounded text-2xl text-gray-25',
|
||||||
danger: 'px-4 py-2 rounded text-base bg-red text-gray-700 shadow hover:bg-red-400 focus:bg-red-400 focus:ring-red',
|
danger: 'px-4 py-2 rounded text-base bg-red text-gray-700 shadow hover:bg-red-400 focus:bg-red-400 focus:ring-red',
|
||||||
warn: 'px-4 py-2 rounded text-base bg-gray-500 text-red-500 shadow hover:bg-gray-400 focus:bg-gray-400 focus:ring-red',
|
warn: 'px-4 py-2 rounded text-base bg-gray-500 text-red-500 shadow hover:bg-gray-400 focus:bg-gray-400 focus:ring-red',
|
||||||
})
|
})
|
||||||
|
@ -42,6 +43,9 @@ export type ButtonProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'a
|
||||||
className?: string
|
className?: string
|
||||||
|
|
||||||
leftIcon?: ReactNode
|
leftIcon?: ReactNode
|
||||||
|
|
||||||
|
/** Display spinner based on the value passed */
|
||||||
|
isLoading?: boolean
|
||||||
}>
|
}>
|
||||||
|
|
||||||
function Button(
|
function Button(
|
||||||
|
@ -53,6 +57,7 @@ function Button(
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
leftIcon,
|
leftIcon,
|
||||||
|
isLoading = false,
|
||||||
...rest
|
...rest
|
||||||
}: ButtonProps,
|
}: ButtonProps,
|
||||||
ref: React.Ref<HTMLElement>
|
ref: React.Ref<HTMLElement>
|
||||||
|
@ -81,8 +86,43 @@ function Button(
|
||||||
>
|
>
|
||||||
{leftIcon && <span className="mr-1.5">{leftIcon}</span>}
|
{leftIcon && <span className="mr-1.5">{leftIcon}</span>}
|
||||||
{children}
|
{children}
|
||||||
|
{isLoading && Tag === 'button' && (
|
||||||
|
<span className="ml-1.5">
|
||||||
|
<Spinner fill={variant === 'primary' ? '#16161A' : '#FFFFFF'} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Tag>
|
</Tag>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add a spinner to Button component and toggle it via flag
|
||||||
|
|
||||||
|
interface SpinnerProps {
|
||||||
|
fill: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function Spinner({ fill }: SpinnerProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
||||||
|
opacity=".25"
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z"
|
||||||
|
fill={fill}
|
||||||
|
>
|
||||||
|
<animateTransform
|
||||||
|
attributeName="transform"
|
||||||
|
type="rotate"
|
||||||
|
dur="0.75s"
|
||||||
|
values="0 12 12;360 12 12"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default React.forwardRef(Button)
|
export default React.forwardRef(Button)
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"presets": [["@nrwl/web/babel", { "useBuiltIns": "usage" }]]
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"extends": ["../../.eslintrc.json"],
|
|
||||||
"ignorePatterns": ["!**/*"],
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
|
||||||
"rules": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": ["*.ts", "*.tsx"],
|
|
||||||
"rules": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": ["*.js", "*.jsx"],
|
|
||||||
"rules": {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
# finicity-api
|
|
||||||
|
|
||||||
This library was generated with [Nx](https://nx.dev).
|
|
||||||
|
|
||||||
## Running unit tests
|
|
||||||
|
|
||||||
Run `nx test finicity-api` to execute the unit tests via [Jest](https://jestjs.io).
|
|
|
@ -1,16 +0,0 @@
|
||||||
/* eslint-disable */
|
|
||||||
export default {
|
|
||||||
displayName: 'finicity-api',
|
|
||||||
preset: '../../jest.preset.js',
|
|
||||||
globals: {
|
|
||||||
'ts-jest': {
|
|
||||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
testEnvironment: 'node',
|
|
||||||
transform: {
|
|
||||||
'^.+\\.[tj]sx?$': 'ts-jest',
|
|
||||||
},
|
|
||||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
|
||||||
coverageDirectory: '../../coverage/libs/finicity-api',
|
|
||||||
}
|
|
|
@ -1,294 +0,0 @@
|
||||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
|
|
||||||
import type {
|
|
||||||
AddCustomerRequest,
|
|
||||||
AddCustomerResponse,
|
|
||||||
AuthenticationResponse,
|
|
||||||
DeleteCustomerAccountsByInstitutionLoginRequest,
|
|
||||||
DeleteCustomerAccountsByInstitutionLoginResponse,
|
|
||||||
GenerateConnectUrlResponse,
|
|
||||||
GenerateFixConnectUrlRequest,
|
|
||||||
GenerateLiteConnectUrlRequest,
|
|
||||||
GetAccountTransactionsRequest,
|
|
||||||
GetAccountTransactionsResponse,
|
|
||||||
GetCustomerAccountRequest,
|
|
||||||
GetCustomerAccountResponse,
|
|
||||||
GetCustomerAccountsRequest,
|
|
||||||
GetCustomerAccountsResponse,
|
|
||||||
GetInstitutionsRequest,
|
|
||||||
GetInstitutionsResponse,
|
|
||||||
LoadHistoricTransactionsRequest,
|
|
||||||
RefreshCustomerAccountRequest,
|
|
||||||
TxPushDisableRequest,
|
|
||||||
TxPushSubscriptionRequest,
|
|
||||||
TxPushSubscriptions,
|
|
||||||
} from './types'
|
|
||||||
import { DateTime } from 'luxon'
|
|
||||||
import axios from 'axios'
|
|
||||||
|
|
||||||
const is2xx = (status: number): boolean => status >= 200 && status < 300
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Basic typed mapping for Finicity API
|
|
||||||
*/
|
|
||||||
export class FinicityApi {
|
|
||||||
private api: AxiosInstance | null = null
|
|
||||||
private tokenTimestamp: DateTime | null = null
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly appKey: string,
|
|
||||||
private readonly partnerId: string,
|
|
||||||
private readonly partnerSecret: string
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for supported financial institutions
|
|
||||||
*
|
|
||||||
* https://api-reference.finicity.com/#/rest/api-endpoints/institutions/get-institutions
|
|
||||||
*/
|
|
||||||
async getInstitutions(options: GetInstitutionsRequest): Promise<GetInstitutionsResponse> {
|
|
||||||
return this.get<GetInstitutionsResponse>(`/institution/v2/institutions`, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enroll an active or testing customer
|
|
||||||
*
|
|
||||||
* https://api-reference.finicity.com/#/rest/api-endpoints/customer/add-customer
|
|
||||||
*/
|
|
||||||
async addCustomer(options: AddCustomerRequest): Promise<AddCustomerResponse> {
|
|
||||||
return this.post<AddCustomerResponse>(`/aggregation/v2/customers/active`, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enroll a testing customer
|
|
||||||
*
|
|
||||||
* https://api-reference.finicity.com/#/rest/api-endpoints/customer/add-testing-customer
|
|
||||||
*/
|
|
||||||
async addTestingCustomer(options: AddCustomerRequest): Promise<AddCustomerResponse> {
|
|
||||||
return this.post<AddCustomerResponse>(`/aggregation/v2/customers/testing`, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a Connect Lite URL
|
|
||||||
*
|
|
||||||
* https://api-reference.finicity.com/#/rest/api-endpoints/connect/generate-v2-lite-connect-url
|
|
||||||
*/
|
|
||||||
async generateLiteConnectUrl(
|
|
||||||
options: Omit<GenerateLiteConnectUrlRequest, 'partnerId'>
|
|
||||||
): Promise<GenerateConnectUrlResponse> {
|
|
||||||
return this.post<{ link: string }>(`/connect/v2/generate/lite`, {
|
|
||||||
partnerId: this.partnerId,
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a Fix Connect URL
|
|
||||||
*
|
|
||||||
* https://api-reference.finicity.com/#/rest/api-endpoints/connect/generate-v2-fix-connect-url
|
|
||||||
*/
|
|
||||||
async generateFixConnectUrl(
|
|
||||||
options: Omit<GenerateFixConnectUrlRequest, 'partnerId'>
|
|
||||||
): Promise<GenerateConnectUrlResponse> {
|
|
||||||
return this.post<GenerateConnectUrlResponse>(`/connect/v2/generate/fix`, {
|
|
||||||
partnerId: this.partnerId,
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get details for all accounts owned by a customer, optionally for a specific institution
|
|
||||||
*
|
|
||||||
* https://api-reference.finicity.com/#/rest/api-endpoints/accounts/get-customer-accounts
|
|
||||||
*/
|
|
||||||
async getCustomerAccounts(
|
|
||||||
options: GetCustomerAccountsRequest
|
|
||||||
): Promise<GetCustomerAccountsResponse> {
|
|
||||||
const { customerId, ...rest } = options
|
|
||||||
|
|
||||||
return this.get<GetCustomerAccountsResponse>(
|
|
||||||
`/aggregation/v2/customers/${customerId}/accounts`,
|
|
||||||
rest,
|
|
||||||
{
|
|
||||||
validateStatus: (status) => is2xx(status) && status !== 203,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get details for an account
|
|
||||||
*
|
|
||||||
* https://api-reference.finicity.com/#/rest/api-endpoints/accounts/get-customer-account
|
|
||||||
*/
|
|
||||||
async getCustomerAccount(
|
|
||||||
options: GetCustomerAccountRequest
|
|
||||||
): Promise<GetCustomerAccountResponse> {
|
|
||||||
const { customerId, accountId, ...rest } = options
|
|
||||||
|
|
||||||
return this.get<GetCustomerAccountResponse>(
|
|
||||||
`/aggregation/v2/customers/${customerId}/accounts/${accountId}`,
|
|
||||||
rest,
|
|
||||||
{
|
|
||||||
validateStatus: (status) => is2xx(status) && status !== 203,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh accounts
|
|
||||||
*
|
|
||||||
* https://api-reference.finicity.com/#/rest/api-endpoints/accounts/refresh-customer-accounts
|
|
||||||
*/
|
|
||||||
async refreshCustomerAccounts({
|
|
||||||
customerId,
|
|
||||||
}: RefreshCustomerAccountRequest): Promise<GetCustomerAccountsResponse> {
|
|
||||||
return this.post<GetCustomerAccountsResponse>(
|
|
||||||
`/aggregation/v1/customers/${customerId}/accounts`,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
timeout: 120_000,
|
|
||||||
validateStatus: (status) => is2xx(status) && status !== 203,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteCustomerAccountsByInstitutionLogin(
|
|
||||||
options: DeleteCustomerAccountsByInstitutionLoginRequest
|
|
||||||
): Promise<DeleteCustomerAccountsByInstitutionLoginResponse> {
|
|
||||||
const { customerId, institutionLoginId, ...rest } = options
|
|
||||||
|
|
||||||
return this.delete<DeleteCustomerAccountsByInstitutionLoginResponse>(
|
|
||||||
`/aggregation/v1/customers/${customerId}/institutionLogins/${institutionLoginId}`,
|
|
||||||
rest
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get transactions for an account
|
|
||||||
*
|
|
||||||
* https://api-reference.finicity.com/#/rest/api-endpoints/transactions/get-customer-account-transactions
|
|
||||||
*/
|
|
||||||
async getAccountTransactions(
|
|
||||||
options: GetAccountTransactionsRequest
|
|
||||||
): Promise<GetAccountTransactionsResponse> {
|
|
||||||
const { customerId, accountId, ...rest } = options
|
|
||||||
|
|
||||||
return this.get<GetAccountTransactionsResponse>(
|
|
||||||
`/aggregation/v4/customers/${customerId}/accounts/${accountId}/transactions`,
|
|
||||||
rest,
|
|
||||||
{
|
|
||||||
validateStatus: (status) => is2xx(status) && status !== 203,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load historic transactions for an account
|
|
||||||
*
|
|
||||||
* https://api-reference.finicity.com/#/rest/api-endpoints/accounts/load-historic-transactions-for-customer-account
|
|
||||||
*/
|
|
||||||
async loadHistoricTransactions({
|
|
||||||
customerId,
|
|
||||||
accountId,
|
|
||||||
}: LoadHistoricTransactionsRequest): Promise<void> {
|
|
||||||
await this.post(
|
|
||||||
`/aggregation/v1/customers/${customerId}/accounts/${accountId}/transactions/historic`,
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
timeout: 180_000,
|
|
||||||
validateStatus: (status) => is2xx(status) && status !== 203,
|
|
||||||
} // 180 second timeout recommended by Finicity
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to TxPUSH notifications
|
|
||||||
*
|
|
||||||
* https://api-reference.finicity.com/#/rest/api-endpoints/txpush/subscribe-to-txpush-notifications
|
|
||||||
*/
|
|
||||||
async subscribeTxPush({
|
|
||||||
customerId,
|
|
||||||
accountId,
|
|
||||||
callbackUrl,
|
|
||||||
}: TxPushSubscriptionRequest): Promise<TxPushSubscriptions> {
|
|
||||||
return this.post(`/aggregation/v1/customers/${customerId}/accounts/${accountId}/txpush`, {
|
|
||||||
callbackUrl,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disable TxPUSH notifications
|
|
||||||
*
|
|
||||||
* https://api-reference.finicity.com/#/rest/api-endpoints/txpush/disable-txpush-notifications
|
|
||||||
*/
|
|
||||||
async disableTxPush({ customerId, accountId }: TxPushDisableRequest): Promise<void> {
|
|
||||||
await this.delete(`/aggregation/v1/customers/${customerId}/accounts/${accountId}/txpush`)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getApi(): Promise<AxiosInstance> {
|
|
||||||
const tokenAge =
|
|
||||||
this.tokenTimestamp && Math.abs(this.tokenTimestamp.diffNow('minutes').minutes)
|
|
||||||
|
|
||||||
// Refresh token if over 90 minutes old (https://api-reference.finicity.com/#/rest/api-endpoints/authentication/partner-authentication)
|
|
||||||
if (!this.api || !tokenAge || (tokenAge && tokenAge > 90)) {
|
|
||||||
const token = (
|
|
||||||
await axios.post<AuthenticationResponse>(
|
|
||||||
'https://api.finicity.com/aggregation/v2/partners/authentication',
|
|
||||||
{
|
|
||||||
partnerId: this.partnerId,
|
|
||||||
partnerSecret: this.partnerSecret,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Finicity-App-Key': this.appKey,
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
).data.token
|
|
||||||
|
|
||||||
this.tokenTimestamp = DateTime.now()
|
|
||||||
|
|
||||||
this.api = axios.create({
|
|
||||||
baseURL: `https://api.finicity.com`,
|
|
||||||
timeout: 30_000,
|
|
||||||
headers: {
|
|
||||||
'Finicity-App-Token': token,
|
|
||||||
'Finicity-App-Key': this.appKey,
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.api
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generic API GET request method */
|
|
||||||
private async get<TResponse>(
|
|
||||||
path: string,
|
|
||||||
params?: any,
|
|
||||||
config?: AxiosRequestConfig
|
|
||||||
): Promise<TResponse> {
|
|
||||||
const api = await this.getApi()
|
|
||||||
return api.get<TResponse>(path, { params, ...config }).then(({ data }) => data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generic API POST request method */
|
|
||||||
private async post<TResponse>(
|
|
||||||
path: string,
|
|
||||||
body?: any,
|
|
||||||
config?: AxiosRequestConfig
|
|
||||||
): Promise<TResponse> {
|
|
||||||
const api = await this.getApi()
|
|
||||||
return api.post<TResponse>(path, body, config).then(({ data }) => data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generic API DELETE request method */
|
|
||||||
private async delete<TResponse>(
|
|
||||||
path: string,
|
|
||||||
params?: any,
|
|
||||||
config?: AxiosRequestConfig
|
|
||||||
): Promise<TResponse> {
|
|
||||||
const api = await this.getApi()
|
|
||||||
return api.delete<TResponse>(path, { params, ...config }).then(({ data }) => data)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,2 +0,0 @@
|
||||||
export * from './finicity-api'
|
|
||||||
export * as FinicityTypes from './types'
|
|
|
@ -1,163 +0,0 @@
|
||||||
/** https://api-reference.finicity.com/#/rest/models/enumerations/account-type */
|
|
||||||
export type AccountType =
|
|
||||||
| 'checking'
|
|
||||||
| 'savings'
|
|
||||||
| 'cd'
|
|
||||||
| 'moneyMarket'
|
|
||||||
| 'creditCard'
|
|
||||||
| 'lineOfCredit'
|
|
||||||
| 'investment'
|
|
||||||
| 'brokerageAccount'
|
|
||||||
| 'pension'
|
|
||||||
| 'profitSharingPlan'
|
|
||||||
| 'investmentTaxDeferred'
|
|
||||||
| 'employeeStockPurchasePlan'
|
|
||||||
| 'ira'
|
|
||||||
| 'simpleIRA'
|
|
||||||
| 'sepIRA'
|
|
||||||
| '401k'
|
|
||||||
| 'roth'
|
|
||||||
| 'roth401k'
|
|
||||||
| '403b'
|
|
||||||
| '529'
|
|
||||||
| '529plan'
|
|
||||||
| 'rollover'
|
|
||||||
| 'ugma'
|
|
||||||
| 'utma'
|
|
||||||
| 'keogh'
|
|
||||||
| '457'
|
|
||||||
| '457plan'
|
|
||||||
| '401a'
|
|
||||||
| 'cryptocurrency'
|
|
||||||
| 'mortgage'
|
|
||||||
| 'loan'
|
|
||||||
| 'studentLoan'
|
|
||||||
| 'studentLoanGroup'
|
|
||||||
| 'studentLoanAccount'
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/customer-account-position */
|
|
||||||
export type CustomerAccountPosition = {
|
|
||||||
[key: string]: any
|
|
||||||
id?: number
|
|
||||||
description?: string
|
|
||||||
securityId?: string
|
|
||||||
securityIdType?: string
|
|
||||||
symbol?: string
|
|
||||||
/** @deprecated finicity still uses this field in lieu of `units` for some accounts (eg. Citibank) as of 2023-01-30 */
|
|
||||||
quantity?: number
|
|
||||||
units?: number
|
|
||||||
currentPrice?: number
|
|
||||||
securityName?: string
|
|
||||||
/** @deprecated undocumented field */
|
|
||||||
fundName?: string
|
|
||||||
transactionType?: string
|
|
||||||
marketValue?: number | string
|
|
||||||
costBasis?: number
|
|
||||||
status?: string
|
|
||||||
currentPriceDate?: number
|
|
||||||
invSecurityType?: string
|
|
||||||
mfType?: string
|
|
||||||
posType?: string
|
|
||||||
totalGLDollar?: number
|
|
||||||
totalGLPercent?: number
|
|
||||||
securityType?: string
|
|
||||||
securityCurrency?: string
|
|
||||||
fiAssetClass?: string
|
|
||||||
assetClass?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/customer-account-detail */
|
|
||||||
export type CustomerAccountDetail = {
|
|
||||||
[key: string]: any
|
|
||||||
availableBalanceAmount?: number
|
|
||||||
availableCashBalance?: number
|
|
||||||
interestRate?: string
|
|
||||||
creditAvailableAmount?: number
|
|
||||||
paymentMinAmount?: number
|
|
||||||
statementCloseBalance?: number
|
|
||||||
locPrincipalBalance?: number
|
|
||||||
paymentDueDate?: number
|
|
||||||
statementEndDate?: number
|
|
||||||
vestedBalance?: number
|
|
||||||
currentLoanBalance?: number
|
|
||||||
payoffAmount?: number
|
|
||||||
principalBalance?: number
|
|
||||||
autoPayEnrolled?: 'Y' | 'N'
|
|
||||||
firstMortgage?: 'Y' | 'N'
|
|
||||||
recurringPaymentAmount?: number
|
|
||||||
lender?: string
|
|
||||||
endingBalanceAmount?: number
|
|
||||||
loanTermType?: string
|
|
||||||
paymentsMade?: number
|
|
||||||
balloonAmount?: number
|
|
||||||
paymentsRemaining?: number
|
|
||||||
loanMinAmtDue?: number
|
|
||||||
loanPaymentFreq?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/customer-account */
|
|
||||||
export type CustomerAccount = {
|
|
||||||
[key: string]: any
|
|
||||||
id: string
|
|
||||||
accountNumberDisplay: string
|
|
||||||
realAccountNumberLast4?: string
|
|
||||||
name: string
|
|
||||||
balance?: number
|
|
||||||
type: AccountType
|
|
||||||
aggregationStatusCode?: number
|
|
||||||
status: string
|
|
||||||
customerId: string
|
|
||||||
institutionId: string
|
|
||||||
balanceDate: number
|
|
||||||
aggregationSuccessDate?: number
|
|
||||||
aggregationAttemptDate?: number
|
|
||||||
createdDate: number
|
|
||||||
currency: string
|
|
||||||
lastTransactionDate?: number
|
|
||||||
/** Incorrectly shown as "Required" in Finicity docs */
|
|
||||||
oldestTransactionDate?: number
|
|
||||||
institutionLoginId: number
|
|
||||||
detail?: CustomerAccountDetail
|
|
||||||
position?: CustomerAccountPosition[]
|
|
||||||
displayPosition: number
|
|
||||||
parentAccount?: number
|
|
||||||
|
|
||||||
/** Not in Finicity docs */
|
|
||||||
accountNickname?: string
|
|
||||||
/** Not in Finicity docs */
|
|
||||||
marketSegment?: string
|
|
||||||
|
|
||||||
/** @deprecated */
|
|
||||||
number?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/api-endpoints/accounts/get-customer-accounts */
|
|
||||||
export type GetCustomerAccountsRequest = {
|
|
||||||
customerId: string
|
|
||||||
status?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/customer-accounts */
|
|
||||||
export type GetCustomerAccountsResponse = {
|
|
||||||
accounts: CustomerAccount[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/api-endpoints/accounts/get-customer-account */
|
|
||||||
export type GetCustomerAccountRequest = {
|
|
||||||
customerId: string
|
|
||||||
accountId: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GetCustomerAccountResponse = CustomerAccount
|
|
||||||
|
|
||||||
export type RefreshCustomerAccountRequest = {
|
|
||||||
customerId: string | number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/api-endpoints/accounts/delete-customer-accounts-by-institution-login */
|
|
||||||
export type DeleteCustomerAccountsByInstitutionLoginRequest = {
|
|
||||||
customerId: string
|
|
||||||
institutionLoginId: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DeleteCustomerAccountsByInstitutionLoginResponse = void
|
|
|
@ -1,4 +0,0 @@
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/authentication-response */
|
|
||||||
export type AuthenticationResponse = {
|
|
||||||
token: string
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/generate-connect-url-request-lite-v2 */
|
|
||||||
export type GenerateLiteConnectUrlRequest = {
|
|
||||||
partnerId: string
|
|
||||||
customerId: string
|
|
||||||
institutionId: string
|
|
||||||
redirectUri?: string
|
|
||||||
webhook?: string
|
|
||||||
webhookContentType?: string
|
|
||||||
webhookData?: object
|
|
||||||
webhookHeaders?: object
|
|
||||||
experience?: string
|
|
||||||
singleUseUrl?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/generate-connect-url-request-fix-v2 */
|
|
||||||
export type GenerateFixConnectUrlRequest = {
|
|
||||||
partnerId: string
|
|
||||||
customerId: string
|
|
||||||
institutionLoginId: string | number
|
|
||||||
redirectUri?: string
|
|
||||||
webhook?: string
|
|
||||||
webhookContentType?: string
|
|
||||||
webhookData?: object
|
|
||||||
webhookHeaders?: object
|
|
||||||
experience?: string
|
|
||||||
singleUseUrl?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/generate-connect-url-response */
|
|
||||||
export type GenerateConnectUrlResponse = {
|
|
||||||
link: string
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/add-customer-request */
|
|
||||||
export type AddCustomerRequest = {
|
|
||||||
username: string
|
|
||||||
firstName?: string
|
|
||||||
lastName?: string
|
|
||||||
applicationId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/add-customer-response */
|
|
||||||
export type AddCustomerResponse = {
|
|
||||||
id: string
|
|
||||||
username: string
|
|
||||||
createdDate: string
|
|
||||||
}
|
|
|
@ -1,8 +0,0 @@
|
||||||
export * from './accounts'
|
|
||||||
export * from './authentication'
|
|
||||||
export * from './connect'
|
|
||||||
export * from './customers'
|
|
||||||
export * from './institutions'
|
|
||||||
export * from './transactions'
|
|
||||||
export * from './webhooks'
|
|
||||||
export * from './txpush'
|
|
|
@ -1,65 +0,0 @@
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/institution-address */
|
|
||||||
export type InstitutionAddress = {
|
|
||||||
city?: string
|
|
||||||
state?: string
|
|
||||||
country?: string
|
|
||||||
postalCode?: string
|
|
||||||
addressLine1?: string
|
|
||||||
addressLine2?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/get-institutions-institution-branding */
|
|
||||||
export type InstitutionBranding = {
|
|
||||||
logo?: string
|
|
||||||
alternateLogo?: string
|
|
||||||
icon?: string
|
|
||||||
primaryColor?: string
|
|
||||||
tile?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/institution */
|
|
||||||
export type Institution = {
|
|
||||||
id: number
|
|
||||||
name?: string
|
|
||||||
transAgg: boolean
|
|
||||||
ach: boolean
|
|
||||||
stateAgg: boolean
|
|
||||||
voi: boolean
|
|
||||||
voa: boolean
|
|
||||||
aha: boolean
|
|
||||||
availBalance: boolean
|
|
||||||
accountOwner: boolean
|
|
||||||
accountTypeDescription?: string
|
|
||||||
phone?: string
|
|
||||||
urlHomeApp?: string
|
|
||||||
urlLogonApp?: string
|
|
||||||
oauthEnabled: boolean
|
|
||||||
urlForgotPassword?: string
|
|
||||||
urlOnlineRegistration?: string
|
|
||||||
class?: string
|
|
||||||
specialText?: string
|
|
||||||
specialInstructions?: string[]
|
|
||||||
address?: InstitutionAddress
|
|
||||||
currency: string
|
|
||||||
email?: string
|
|
||||||
status: string
|
|
||||||
newInstitutionId?: number
|
|
||||||
branding?: InstitutionBranding
|
|
||||||
oauthInstitutionId?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/api-endpoints/institutions/get-institutions */
|
|
||||||
export type GetInstitutionsRequest = {
|
|
||||||
search?: string
|
|
||||||
start?: number
|
|
||||||
limit?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/get-institutions-response */
|
|
||||||
export type GetInstitutionsResponse = {
|
|
||||||
found: number
|
|
||||||
displaying: number
|
|
||||||
moreAvailable: boolean
|
|
||||||
createdDate: string
|
|
||||||
institutions: Institution[]
|
|
||||||
}
|
|
|
@ -1,98 +0,0 @@
|
||||||
/** https://api-reference.finicity.com/#/rest/models/enumerations/transaction-type */
|
|
||||||
export type TransactionType =
|
|
||||||
| 'atm'
|
|
||||||
| 'cash'
|
|
||||||
| 'check'
|
|
||||||
| 'credit'
|
|
||||||
| 'debit'
|
|
||||||
| 'deposit'
|
|
||||||
| 'directDebit'
|
|
||||||
| 'directDeposit'
|
|
||||||
| 'dividend'
|
|
||||||
| 'fee'
|
|
||||||
| 'interest'
|
|
||||||
| 'other'
|
|
||||||
| 'payment'
|
|
||||||
| 'pointOfSale'
|
|
||||||
| 'repeatPayment'
|
|
||||||
| 'serviceCharge'
|
|
||||||
| 'transfer'
|
|
||||||
| 'DIV' // undocumented
|
|
||||||
| 'SRVCHG' // undocumented
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/categorization */
|
|
||||||
export type TransactionCategorization = {
|
|
||||||
[key: string]: any
|
|
||||||
normalizedPayeeName: string
|
|
||||||
/** https://api-reference.finicity.com/#/rest/models/enumerations/categories */
|
|
||||||
category: string
|
|
||||||
city?: string
|
|
||||||
state?: string
|
|
||||||
postalCode?: string
|
|
||||||
country: string
|
|
||||||
bestRepresentation?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/transaction */
|
|
||||||
export type Transaction = {
|
|
||||||
[key: string]: any
|
|
||||||
id: number
|
|
||||||
amount: number
|
|
||||||
accountId: number
|
|
||||||
customerId: number
|
|
||||||
status: 'active' | 'pending' | 'shadow'
|
|
||||||
description: string
|
|
||||||
memo?: string
|
|
||||||
postedDate: number
|
|
||||||
transactionDate?: number
|
|
||||||
effectiveDate?: number
|
|
||||||
firstEffectiveDate?: number
|
|
||||||
createdDate: number
|
|
||||||
type?: TransactionType | string
|
|
||||||
checkNum?: number
|
|
||||||
escrowAmount?: number
|
|
||||||
feeAmount?: number
|
|
||||||
interestAmount?: number
|
|
||||||
principalAmount?: number
|
|
||||||
unitQuantity?: number
|
|
||||||
unitPrice?: number
|
|
||||||
categorization?: TransactionCategorization
|
|
||||||
subaccountSecurityType?: string
|
|
||||||
commissionAmount?: number
|
|
||||||
symbol?: string
|
|
||||||
ticker?: string
|
|
||||||
investmentTransactionType?: string
|
|
||||||
taxesAmount?: number
|
|
||||||
currencySymbol?: string
|
|
||||||
securityId?: string
|
|
||||||
securityIdType?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/api-endpoints/transactions/get-customer-account-transactions */
|
|
||||||
export type GetAccountTransactionsRequest = {
|
|
||||||
customerId: string
|
|
||||||
accountId: string
|
|
||||||
fromDate: number
|
|
||||||
toDate: number
|
|
||||||
start?: number
|
|
||||||
limit?: number
|
|
||||||
sort?: 'asc' | 'desc'
|
|
||||||
includePending?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/models/structures/get-transactions-response */
|
|
||||||
export type GetAccountTransactionsResponse = {
|
|
||||||
found: number
|
|
||||||
displaying: number
|
|
||||||
moreAvailable: string
|
|
||||||
fromDate: string
|
|
||||||
toDate: string
|
|
||||||
sort: string
|
|
||||||
transactions: Transaction[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https://api-reference.finicity.com/#/rest/api-endpoints/accounts/load-historic-transactions-for-customer-account */
|
|
||||||
export type LoadHistoricTransactionsRequest = {
|
|
||||||
customerId: string
|
|
||||||
accountId: string
|
|
||||||
}
|
|
|
@ -1,41 +0,0 @@
|
||||||
import type { CustomerAccount } from './accounts'
|
|
||||||
import type { Transaction } from './transactions'
|
|
||||||
|
|
||||||
export type TxPushSubscriptionRequest = {
|
|
||||||
customerId: string | number
|
|
||||||
accountId: string | number
|
|
||||||
callbackUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type SubscriptionRecord = {
|
|
||||||
id: number
|
|
||||||
accountId: number
|
|
||||||
type: 'account' | 'transaction'
|
|
||||||
callbackUrl: string
|
|
||||||
signingKey: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TxPushSubscriptions = {
|
|
||||||
subscriptions: SubscriptionRecord[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TxPushEvent =
|
|
||||||
| {
|
|
||||||
class: 'transaction'
|
|
||||||
type: 'created' | 'modified' | 'deleted'
|
|
||||||
records: Transaction[]
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
class: 'account'
|
|
||||||
type: 'modified' | 'deleted'
|
|
||||||
records: CustomerAccount[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TxPushEventMessage = {
|
|
||||||
event: TxPushEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TxPushDisableRequest = {
|
|
||||||
customerId: string | number
|
|
||||||
accountId: string | number
|
|
||||||
}
|
|
|
@ -1,37 +0,0 @@
|
||||||
import type { CustomerAccount } from './accounts'
|
|
||||||
|
|
||||||
/** https://docs.finicity.com/webhook-events-list/#webhooks-2-3 */
|
|
||||||
export type WebhookData =
|
|
||||||
| {
|
|
||||||
eventType: 'ping'
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
eventType: 'added' | 'discovered'
|
|
||||||
payload: {
|
|
||||||
accounts: CustomerAccount[]
|
|
||||||
institutionId: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
eventType: 'done'
|
|
||||||
customerId: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
eventType: 'institutionNotFound'
|
|
||||||
payload: {
|
|
||||||
query: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
eventType: 'institutionNotSupported'
|
|
||||||
payload: {
|
|
||||||
institutionId: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
eventType: 'unableToConnect'
|
|
||||||
payload: {
|
|
||||||
institutionId: string
|
|
||||||
code: number
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"files": [],
|
|
||||||
"include": [],
|
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": "./tsconfig.lib.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "./tsconfig.spec.json"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "commonjs",
|
|
||||||
"outDir": "../../dist/out-tsc",
|
|
||||||
"declaration": true,
|
|
||||||
"types": ["node"]
|
|
||||||
},
|
|
||||||
"exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
|
|
||||||
"include": ["**/*.ts"]
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "../../dist/out-tsc",
|
|
||||||
"module": "commonjs",
|
|
||||||
"types": ["jest", "node"]
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"**/*.test.ts",
|
|
||||||
"**/*.spec.ts",
|
|
||||||
"**/*.test.tsx",
|
|
||||||
"**/*.spec.tsx",
|
|
||||||
"**/*.test.js",
|
|
||||||
"**/*.spec.js",
|
|
||||||
"**/*.test.jsx",
|
|
||||||
"**/*.spec.jsx",
|
|
||||||
"**/*.d.ts",
|
|
||||||
"jest.config.ts"
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -69,10 +69,8 @@ export class InvestmentTransactionBalanceSyncStrategy extends BalanceSyncStrateg
|
||||||
WHERE
|
WHERE
|
||||||
it.account_id = ${pAccountId}
|
it.account_id = ${pAccountId}
|
||||||
AND it.date BETWEEN ${pStart} AND now()
|
AND it.date BETWEEN ${pStart} AND now()
|
||||||
AND ( -- filter for transactions that modify a position
|
-- filter for transactions that modify a position
|
||||||
it.plaid_type IN ('buy', 'sell', 'transfer')
|
AND it.category IN ('buy', 'sell', 'transfer')
|
||||||
OR it.finicity_transaction_id IS NOT NULL
|
|
||||||
)
|
|
||||||
GROUP BY
|
GROUP BY
|
||||||
1, 2
|
1, 2
|
||||||
) it ON it.security_id = s.id AND it.date = d.date
|
) it ON it.security_id = s.id AND it.date = d.date
|
||||||
|
|
|
@ -153,13 +153,7 @@ export class AccountConnectionService implements IAccountConnectionService {
|
||||||
])
|
])
|
||||||
|
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
`Disconnected connection id=${connection.id} type=${
|
`Disconnected connection id=${connection.id} type=${connection.type} provider_connection_id=${connection.plaidItemId}`
|
||||||
connection.type
|
|
||||||
} provider_connection_id=${
|
|
||||||
connection.type === 'plaid'
|
|
||||||
? connection.plaidItemId
|
|
||||||
: connection.finicityInstitutionId
|
|
||||||
}`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return connection
|
return connection
|
||||||
|
@ -182,13 +176,7 @@ export class AccountConnectionService implements IAccountConnectionService {
|
||||||
])
|
])
|
||||||
|
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
`Reconnected connection id=${connection.id} type=${
|
`Reconnected connection id=${connection.id} type=${connection.type} provider_connection_id=${connection.plaidItemId}`
|
||||||
connection.type
|
|
||||||
} provider_connection_id=${
|
|
||||||
connection.type === 'plaid'
|
|
||||||
? connection.plaidItemId
|
|
||||||
: connection.finicityInstitutionId
|
|
||||||
}`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return connection
|
return connection
|
||||||
|
@ -213,13 +201,7 @@ export class AccountConnectionService implements IAccountConnectionService {
|
||||||
})
|
})
|
||||||
|
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
`Deleted connection id=${deletedConnection.id} type=${
|
`Deleted connection id=${deletedConnection.id} type=${connection.type} provider_connection_id=${connection.plaidItemId}`
|
||||||
connection.type
|
|
||||||
} provider_connection_id=${
|
|
||||||
connection.type === 'plaid'
|
|
||||||
? connection.plaidItemId
|
|
||||||
: connection.finicityInstitutionId
|
|
||||||
}`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return deletedConnection
|
return deletedConnection
|
||||||
|
|
|
@ -242,12 +242,7 @@ export class AccountQueryService implements IAccountQueryService {
|
||||||
it.account_id = ANY(${pAccountIds})
|
it.account_id = ANY(${pAccountIds})
|
||||||
AND it.date BETWEEN sd.start_date AND ${pEnd}
|
AND it.date BETWEEN sd.start_date AND ${pEnd}
|
||||||
-- filter for investment_transactions that represent external flows
|
-- filter for investment_transactions that represent external flows
|
||||||
AND (
|
AND it.category = 'transfer'
|
||||||
(it.plaid_type = 'cash' AND it.plaid_subtype IN ('contribution', 'deposit', 'withdrawal'))
|
|
||||||
OR (it.plaid_type = 'transfer' AND it.plaid_subtype IN ('transfer'))
|
|
||||||
OR (it.plaid_type = 'buy' AND it.plaid_subtype IN ('contribution'))
|
|
||||||
OR (it.finicity_transaction_id IS NOT NULL AND it.finicity_investment_transaction_type IN ('contribution', 'deposit', 'transfer'))
|
|
||||||
)
|
|
||||||
GROUP BY
|
GROUP BY
|
||||||
1, 2
|
1, 2
|
||||||
), external_flow_totals AS (
|
), external_flow_totals AS (
|
||||||
|
|
|
@ -194,10 +194,6 @@ export const AccountUpdateSchema = z.discriminatedUnion('provider', [
|
||||||
provider: z.literal('plaid'),
|
provider: z.literal('plaid'),
|
||||||
data: ProviderAccountUpdateSchema,
|
data: ProviderAccountUpdateSchema,
|
||||||
}),
|
}),
|
||||||
z.object({
|
|
||||||
provider: z.literal('finicity'),
|
|
||||||
data: ProviderAccountUpdateSchema,
|
|
||||||
}),
|
|
||||||
z.object({
|
z.object({
|
||||||
provider: z.literal('user'),
|
provider: z.literal('user'),
|
||||||
data: UserAccountUpdateSchema,
|
data: UserAccountUpdateSchema,
|
||||||
|
|
|
@ -312,9 +312,7 @@ export class InsightService implements IInsightService {
|
||||||
{
|
{
|
||||||
plaidSubtype: 'dividend',
|
plaidSubtype: 'dividend',
|
||||||
},
|
},
|
||||||
{
|
{ category: 'dividend' },
|
||||||
finicityInvestmentTransactionType: 'dividend',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
@ -649,9 +647,6 @@ export class InsightService implements IInsightService {
|
||||||
WHEN plaid_type IN ('fixed income') THEN 'fixed_income'
|
WHEN plaid_type IN ('fixed income') THEN 'fixed_income'
|
||||||
WHEN plaid_type IN ('cash', 'loan') THEN 'cash'
|
WHEN plaid_type IN ('cash', 'loan') THEN 'cash'
|
||||||
WHEN plaid_type IN ('cryptocurrency') THEN 'crypto'
|
WHEN plaid_type IN ('cryptocurrency') THEN 'crypto'
|
||||||
-- finicity
|
|
||||||
WHEN finicity_type IN ('EQUITY', 'ETF', 'MUTUALFUND', 'STOCKINFO', 'MFINFO') THEN 'stocks'
|
|
||||||
WHEN finicity_type IN ('BOND') THEN 'fixed_income'
|
|
||||||
ELSE 'other'
|
ELSE 'other'
|
||||||
END AS "asset_class"
|
END AS "asset_class"
|
||||||
FROM
|
FROM
|
||||||
|
@ -705,9 +700,6 @@ export class InsightService implements IInsightService {
|
||||||
WHEN s.plaid_type IN ('fixed income') THEN 'fixed_income'
|
WHEN s.plaid_type IN ('fixed income') THEN 'fixed_income'
|
||||||
WHEN s.plaid_type IN ('cash', 'loan') THEN 'cash'
|
WHEN s.plaid_type IN ('cash', 'loan') THEN 'cash'
|
||||||
WHEN s.plaid_type IN ('cryptocurrency') THEN 'crypto'
|
WHEN s.plaid_type IN ('cryptocurrency') THEN 'crypto'
|
||||||
-- finicity
|
|
||||||
WHEN s.finicity_type IN ('EQUITY', 'ETF', 'MUTUALFUND', 'STOCKINFO', 'MFINFO') THEN 'stocks'
|
|
||||||
WHEN s.finicity_type IN ('BOND') THEN 'fixed_income'
|
|
||||||
ELSE 'other'
|
ELSE 'other'
|
||||||
END AS "category"
|
END AS "category"
|
||||||
) x ON TRUE
|
) x ON TRUE
|
||||||
|
@ -746,12 +738,7 @@ export class InsightService implements IInsightService {
|
||||||
LEFT JOIN account a ON a.id = it.account_id
|
LEFT JOIN account a ON a.id = it.account_id
|
||||||
WHERE
|
WHERE
|
||||||
it.account_id = ${accountId}
|
it.account_id = ${accountId}
|
||||||
AND (
|
AND it.category = 'transfer'
|
||||||
(it.plaid_type = 'cash' AND it.plaid_subtype IN ('contribution', 'deposit', 'withdrawal'))
|
|
||||||
OR (it.plaid_type = 'transfer' AND it.plaid_subtype IN ('transfer', 'send', 'request'))
|
|
||||||
OR (it.plaid_type = 'buy' AND it.plaid_subtype IN ('contribution'))
|
|
||||||
OR (it.finicity_transaction_id IS NOT NULL AND it.finicity_investment_transaction_type IN ('contribution', 'deposit', 'transfer'))
|
|
||||||
)
|
|
||||||
-- Exclude any contributions made prior to the start date since balances will be 0
|
-- Exclude any contributions made prior to the start date since balances will be 0
|
||||||
AND (a.start_date is NULL OR it.date >= a.start_date)
|
AND (a.start_date is NULL OR it.date >= a.start_date)
|
||||||
GROUP BY 1
|
GROUP BY 1
|
||||||
|
@ -854,9 +841,6 @@ export class InsightService implements IInsightService {
|
||||||
WHEN plaid_type IN ('fixed income') THEN 'bonds'
|
WHEN plaid_type IN ('fixed income') THEN 'bonds'
|
||||||
WHEN plaid_type IN ('cash', 'loan') THEN 'cash'
|
WHEN plaid_type IN ('cash', 'loan') THEN 'cash'
|
||||||
WHEN plaid_type IN ('cryptocurrency') THEN 'crypto'
|
WHEN plaid_type IN ('cryptocurrency') THEN 'crypto'
|
||||||
-- finicity
|
|
||||||
WHEN finicity_type IN ('EQUITY', 'ETF', 'MUTUALFUND', 'STOCKINFO', 'MFINFO') THEN 'stocks'
|
|
||||||
WHEN finicity_type IN ('BOND') THEN 'bonds'
|
|
||||||
ELSE 'other'
|
ELSE 'other'
|
||||||
END AS "asset_type"
|
END AS "asset_type"
|
||||||
FROM
|
FROM
|
||||||
|
|
|
@ -1,662 +0,0 @@
|
||||||
import type { AccountConnection, PrismaClient } from '@prisma/client'
|
|
||||||
import type { Logger } from 'winston'
|
|
||||||
import { SharedUtil, AccountUtil, type SharedType } from '@maybe-finance/shared'
|
|
||||||
import type { FinicityApi, FinicityTypes } from '@maybe-finance/finicity-api'
|
|
||||||
import { DbUtil, FinicityUtil, type IETL } from '@maybe-finance/server/shared'
|
|
||||||
import { Prisma } from '@prisma/client'
|
|
||||||
import _ from 'lodash'
|
|
||||||
import { DateTime } from 'luxon'
|
|
||||||
|
|
||||||
type FinicitySecurity = {
|
|
||||||
securityName: string | undefined
|
|
||||||
symbol: string | undefined
|
|
||||||
currentPrice: number | undefined
|
|
||||||
currentPriceDate: number | undefined
|
|
||||||
securityId: string
|
|
||||||
securityIdType: string
|
|
||||||
type: string | undefined
|
|
||||||
assetClass: string | undefined
|
|
||||||
fiAssetClass: string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FinicityRawData = {
|
|
||||||
accounts: FinicityTypes.CustomerAccount[]
|
|
||||||
transactions: FinicityTypes.Transaction[]
|
|
||||||
transactionsDateRange: SharedType.DateRange<DateTime>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FinicityData = {
|
|
||||||
accounts: FinicityTypes.CustomerAccount[]
|
|
||||||
positions: (FinicityTypes.CustomerAccountPosition & {
|
|
||||||
accountId: FinicityTypes.CustomerAccount['id']
|
|
||||||
security: FinicitySecurity
|
|
||||||
})[]
|
|
||||||
transactions: FinicityTypes.Transaction[]
|
|
||||||
transactionsDateRange: SharedType.DateRange<DateTime>
|
|
||||||
investmentTransactions: (FinicityTypes.Transaction & {
|
|
||||||
security: Pick<FinicitySecurity, 'securityId' | 'securityIdType' | 'symbol'> | null
|
|
||||||
})[]
|
|
||||||
investmentTransactionsDateRange: SharedType.DateRange<DateTime>
|
|
||||||
}
|
|
||||||
|
|
||||||
type Connection = Pick<
|
|
||||||
AccountConnection,
|
|
||||||
'id' | 'userId' | 'finicityInstitutionId' | 'finicityInstitutionLoginId'
|
|
||||||
>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if a Finicity Transaction should be treated as an investment_transaction
|
|
||||||
*/
|
|
||||||
function isInvestmentTransaction(
|
|
||||||
t: Pick<
|
|
||||||
FinicityTypes.Transaction,
|
|
||||||
'securityId' | 'symbol' | 'ticker' | 'investmentTransactionType'
|
|
||||||
>
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
t.securityId != null ||
|
|
||||||
t.symbol != null ||
|
|
||||||
t.ticker != null ||
|
|
||||||
t.investmentTransactionType != null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes Finicity identifiers to handle cases where transactions/positions don't contain a valid
|
|
||||||
* securityId/securityIdType pair
|
|
||||||
*/
|
|
||||||
function getSecurityIdAndType(
|
|
||||||
txnOrPos: Pick<
|
|
||||||
FinicityTypes.Transaction | FinicityTypes.CustomerAccountPosition,
|
|
||||||
'securityId' | 'securityIdType' | 'symbol' | 'ticker'
|
|
||||||
>
|
|
||||||
): { securityId: string; securityIdType: string } | null {
|
|
||||||
const securityId: string | null | undefined =
|
|
||||||
txnOrPos.securityId || txnOrPos.symbol || txnOrPos.ticker
|
|
||||||
|
|
||||||
if (!securityId) return null
|
|
||||||
|
|
||||||
const securityIdType =
|
|
||||||
txnOrPos.securityIdType ||
|
|
||||||
(txnOrPos.securityId ? '__SECURITY_ID__' : txnOrPos.symbol ? '__SYMBOL__' : '__TICKER__')
|
|
||||||
|
|
||||||
return {
|
|
||||||
securityId,
|
|
||||||
securityIdType,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** returns unique identifier for a given security (used for de-duping) */
|
|
||||||
function getSecurityId(s: Pick<FinicitySecurity, 'securityId' | 'securityIdType'>): string {
|
|
||||||
return `${s.securityIdType}|${s.securityId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FinicityETL implements IETL<Connection, FinicityRawData, FinicityData> {
|
|
||||||
public constructor(
|
|
||||||
private readonly logger: Logger,
|
|
||||||
private readonly prisma: PrismaClient,
|
|
||||||
private readonly finicity: Pick<
|
|
||||||
FinicityApi,
|
|
||||||
'getCustomerAccounts' | 'getAccountTransactions'
|
|
||||||
>
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async extract(connection: Connection): Promise<FinicityRawData> {
|
|
||||||
if (!connection.finicityInstitutionId || !connection.finicityInstitutionLoginId) {
|
|
||||||
throw new Error(
|
|
||||||
`connection ${connection.id} is missing finicityInstitutionId or finicityInstitutionLoginId`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.prisma.user.findUniqueOrThrow({
|
|
||||||
where: { id: connection.userId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
finicityCustomerId: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!user.finicityCustomerId) {
|
|
||||||
throw new Error(`user ${user.id} is missing finicityCustomerId`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const transactionsDateRange = {
|
|
||||||
start: DateTime.now().minus(FinicityUtil.FINICITY_WINDOW_MAX),
|
|
||||||
end: DateTime.now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
const accounts = await this._extractAccounts(
|
|
||||||
user.finicityCustomerId,
|
|
||||||
connection.finicityInstitutionLoginId
|
|
||||||
)
|
|
||||||
|
|
||||||
const transactions = await this._extractTransactions(
|
|
||||||
user.finicityCustomerId,
|
|
||||||
accounts.map((a) => a.id),
|
|
||||||
transactionsDateRange
|
|
||||||
)
|
|
||||||
|
|
||||||
this.logger.info(
|
|
||||||
`Extracted Finicity data for customer ${user.finicityCustomerId} accounts=${accounts.length} transactions=${transactions.length}`,
|
|
||||||
{ connection: connection.id, transactionsDateRange }
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
accounts,
|
|
||||||
transactions,
|
|
||||||
transactionsDateRange,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
transform(
|
|
||||||
connection: Connection,
|
|
||||||
{ accounts, transactions, transactionsDateRange }: FinicityRawData
|
|
||||||
): Promise<FinicityData> {
|
|
||||||
const positions = accounts.flatMap(
|
|
||||||
(a) =>
|
|
||||||
a.position
|
|
||||||
?.filter((p) => p.securityId != null || p.symbol != null)
|
|
||||||
.map((p) => ({
|
|
||||||
...p,
|
|
||||||
accountId: a.id,
|
|
||||||
marketValue: p.marketValue ? +p.marketValue || 0 : 0,
|
|
||||||
security: {
|
|
||||||
...getSecurityIdAndType(p)!,
|
|
||||||
securityName: p.securityName ?? p.fundName,
|
|
||||||
symbol: p.symbol,
|
|
||||||
currentPrice: p.currentPrice,
|
|
||||||
currentPriceDate: p.currentPriceDate,
|
|
||||||
type: p.securityType,
|
|
||||||
assetClass: p.assetClass,
|
|
||||||
fiAssetClass: p.fiAssetClass,
|
|
||||||
},
|
|
||||||
})) ?? []
|
|
||||||
)
|
|
||||||
|
|
||||||
const [_investmentTransactions, _transactions] = _(transactions)
|
|
||||||
.uniqBy((t) => t.id)
|
|
||||||
.partition((t) => isInvestmentTransaction(t))
|
|
||||||
.value()
|
|
||||||
|
|
||||||
this.logger.info(
|
|
||||||
`Transformed Finicity transactions positions=${positions.length} transactions=${_transactions.length} investment_transactions=${_investmentTransactions.length}`,
|
|
||||||
{ connection: connection.id }
|
|
||||||
)
|
|
||||||
|
|
||||||
return Promise.resolve<FinicityData>({
|
|
||||||
accounts,
|
|
||||||
positions,
|
|
||||||
transactions: _transactions,
|
|
||||||
transactionsDateRange,
|
|
||||||
investmentTransactions: _investmentTransactions.map((it) => {
|
|
||||||
const security = getSecurityIdAndType(it)
|
|
||||||
return {
|
|
||||||
...it,
|
|
||||||
security: security
|
|
||||||
? {
|
|
||||||
...security,
|
|
||||||
symbol: it.symbol || it.ticker,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
investmentTransactionsDateRange: transactionsDateRange,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async load(connection: Connection, data: FinicityData): Promise<void> {
|
|
||||||
await this.prisma.$transaction([
|
|
||||||
...this._loadAccounts(connection, data),
|
|
||||||
...this._loadPositions(connection, data),
|
|
||||||
...this._loadTransactions(connection, data),
|
|
||||||
...this._loadInvestmentTransactions(connection, data),
|
|
||||||
])
|
|
||||||
|
|
||||||
this.logger.info(`Loaded Finicity data for connection ${connection.id}`, {
|
|
||||||
connection: connection.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _extractAccounts(customerId: string, institutionLoginId: string) {
|
|
||||||
const { accounts } = await this.finicity.getCustomerAccounts({ customerId })
|
|
||||||
|
|
||||||
return accounts.filter(
|
|
||||||
(a) => a.institutionLoginId.toString() === institutionLoginId && a.currency === 'USD'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private _loadAccounts(connection: Connection, { accounts }: Pick<FinicityData, 'accounts'>) {
|
|
||||||
return [
|
|
||||||
// upsert accounts
|
|
||||||
...accounts.map((finicityAccount) => {
|
|
||||||
const type = FinicityUtil.getType(finicityAccount)
|
|
||||||
const classification = AccountUtil.getClassification(type)
|
|
||||||
|
|
||||||
return this.prisma.account.upsert({
|
|
||||||
where: {
|
|
||||||
accountConnectionId_finicityAccountId: {
|
|
||||||
accountConnectionId: connection.id,
|
|
||||||
finicityAccountId: finicityAccount.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
accountConnectionId: connection.id,
|
|
||||||
finicityAccountId: finicityAccount.id,
|
|
||||||
type: FinicityUtil.getType(finicityAccount),
|
|
||||||
provider: 'finicity',
|
|
||||||
categoryProvider: FinicityUtil.getAccountCategory(finicityAccount),
|
|
||||||
subcategoryProvider: finicityAccount.type,
|
|
||||||
name: finicityAccount.name,
|
|
||||||
mask: finicityAccount.accountNumberDisplay,
|
|
||||||
finicityType: finicityAccount.type,
|
|
||||||
finicityDetail: finicityAccount.detail,
|
|
||||||
...FinicityUtil.getAccountBalanceData(finicityAccount, classification),
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
type: FinicityUtil.getType(finicityAccount),
|
|
||||||
categoryProvider: FinicityUtil.getAccountCategory(finicityAccount),
|
|
||||||
subcategoryProvider: finicityAccount.type,
|
|
||||||
finicityType: finicityAccount.type,
|
|
||||||
finicityDetail: finicityAccount.detail,
|
|
||||||
..._.omit(
|
|
||||||
FinicityUtil.getAccountBalanceData(finicityAccount, classification),
|
|
||||||
['currentBalanceStrategy', 'availableBalanceStrategy']
|
|
||||||
),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
// any accounts that are no longer in Finicity should be marked inactive
|
|
||||||
this.prisma.account.updateMany({
|
|
||||||
where: {
|
|
||||||
accountConnectionId: connection.id,
|
|
||||||
AND: [
|
|
||||||
{ finicityAccountId: { not: null } },
|
|
||||||
{ finicityAccountId: { notIn: accounts.map((a) => a.id) } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
isActive: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
private _loadPositions(connection: Connection, { positions }: Pick<FinicityData, 'positions'>) {
|
|
||||||
const securities = _(positions)
|
|
||||||
.map((p) => p.security)
|
|
||||||
.uniqBy((s) => getSecurityId(s))
|
|
||||||
.value()
|
|
||||||
|
|
||||||
const securitiesWithPrices = securities.filter((s) => s.currentPrice != null)
|
|
||||||
|
|
||||||
return [
|
|
||||||
...(securities.length > 0
|
|
||||||
? [
|
|
||||||
// upsert securities
|
|
||||||
this.prisma.$executeRaw`
|
|
||||||
INSERT INTO security (finicity_security_id, finicity_security_id_type, name, symbol, finicity_type, finicity_asset_class, finicity_fi_asset_class)
|
|
||||||
VALUES
|
|
||||||
${Prisma.join(
|
|
||||||
securities.map(
|
|
||||||
({
|
|
||||||
securityId,
|
|
||||||
securityIdType,
|
|
||||||
securityName,
|
|
||||||
symbol,
|
|
||||||
type,
|
|
||||||
assetClass,
|
|
||||||
fiAssetClass,
|
|
||||||
}) =>
|
|
||||||
Prisma.sql`(
|
|
||||||
${securityId},
|
|
||||||
${securityIdType},
|
|
||||||
${securityName},
|
|
||||||
${symbol},
|
|
||||||
${type},
|
|
||||||
${assetClass},
|
|
||||||
${fiAssetClass}
|
|
||||||
)`
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
ON CONFLICT (finicity_security_id, finicity_security_id_type) DO UPDATE
|
|
||||||
SET
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
symbol = EXCLUDED.symbol,
|
|
||||||
finicity_type = EXCLUDED.finicity_type,
|
|
||||||
finicity_asset_class = EXCLUDED.finicity_asset_class,
|
|
||||||
finicity_fi_asset_class = EXCLUDED.finicity_fi_asset_class
|
|
||||||
`,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(securitiesWithPrices.length > 0
|
|
||||||
? [
|
|
||||||
// upsert security prices
|
|
||||||
this.prisma.$executeRaw`
|
|
||||||
INSERT INTO security_pricing (security_id, date, price_close, source)
|
|
||||||
VALUES
|
|
||||||
${Prisma.join(
|
|
||||||
securitiesWithPrices.map(
|
|
||||||
({
|
|
||||||
securityId,
|
|
||||||
securityIdType,
|
|
||||||
currentPrice,
|
|
||||||
currentPriceDate,
|
|
||||||
}) =>
|
|
||||||
Prisma.sql`(
|
|
||||||
(SELECT id FROM security WHERE finicity_security_id = ${securityId} AND finicity_security_id_type = ${securityIdType}),
|
|
||||||
${
|
|
||||||
currentPriceDate
|
|
||||||
? DateTime.fromSeconds(currentPriceDate, {
|
|
||||||
zone: 'utc',
|
|
||||||
}).toISODate()
|
|
||||||
: DateTime.now().toISODate()
|
|
||||||
}::date,
|
|
||||||
${currentPrice},
|
|
||||||
'finicity'
|
|
||||||
)`
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
`,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(positions.length > 0
|
|
||||||
? [
|
|
||||||
// upsert holdings
|
|
||||||
this.prisma.$executeRaw`
|
|
||||||
INSERT INTO holding (finicity_position_id, account_id, security_id, value, quantity, cost_basis_provider, currency_code)
|
|
||||||
VALUES
|
|
||||||
${Prisma.join(
|
|
||||||
// de-dupe positions in case Finicity returns duplicate account/security pairs (they do for test accounts)
|
|
||||||
_.uniqBy(
|
|
||||||
positions,
|
|
||||||
(p) => `${p.accountId}.${getSecurityId(p.security)}`
|
|
||||||
).map(
|
|
||||||
({
|
|
||||||
id,
|
|
||||||
accountId,
|
|
||||||
security: { securityId, securityIdType },
|
|
||||||
units,
|
|
||||||
quantity,
|
|
||||||
marketValue,
|
|
||||||
costBasis,
|
|
||||||
}) =>
|
|
||||||
Prisma.sql`(
|
|
||||||
${id},
|
|
||||||
(SELECT id FROM account WHERE account_connection_id = ${
|
|
||||||
connection.id
|
|
||||||
} AND finicity_account_id = ${accountId}),
|
|
||||||
(SELECT id FROM security WHERE finicity_security_id = ${securityId} AND finicity_security_id_type = ${securityIdType}),
|
|
||||||
${marketValue || 0},
|
|
||||||
${units ?? quantity ?? 0},
|
|
||||||
${costBasis},
|
|
||||||
${'USD'}
|
|
||||||
)`
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
ON CONFLICT (finicity_position_id) DO UPDATE
|
|
||||||
SET
|
|
||||||
account_id = EXCLUDED.account_id,
|
|
||||||
security_id = EXCLUDED.security_id,
|
|
||||||
value = EXCLUDED.value,
|
|
||||||
quantity = EXCLUDED.quantity,
|
|
||||||
cost_basis_provider = EXCLUDED.cost_basis_provider,
|
|
||||||
currency_code = EXCLUDED.currency_code;
|
|
||||||
`,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
// Any holdings that are no longer in Finicity should be deleted
|
|
||||||
this.prisma.holding.deleteMany({
|
|
||||||
where: {
|
|
||||||
account: {
|
|
||||||
accountConnectionId: connection.id,
|
|
||||||
},
|
|
||||||
AND: [
|
|
||||||
{ finicityPositionId: { not: null } },
|
|
||||||
{
|
|
||||||
finicityPositionId: {
|
|
||||||
notIn: positions
|
|
||||||
.map((p) => p.id?.toString())
|
|
||||||
.filter((id): id is string => id != null),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _extractTransactions(
|
|
||||||
customerId: string,
|
|
||||||
accountIds: string[],
|
|
||||||
dateRange: SharedType.DateRange<DateTime>
|
|
||||||
) {
|
|
||||||
const accountTransactions = await Promise.all(
|
|
||||||
accountIds.map((accountId) =>
|
|
||||||
SharedUtil.paginate({
|
|
||||||
pageSize: 1000, // https://api-reference.finicity.com/#/rest/api-endpoints/transactions/get-customer-account-transactions
|
|
||||||
fetchData: async (offset, count) => {
|
|
||||||
const { transactions } = await SharedUtil.withRetry(
|
|
||||||
() =>
|
|
||||||
this.finicity.getAccountTransactions({
|
|
||||||
customerId,
|
|
||||||
accountId,
|
|
||||||
fromDate: dateRange.start.toUnixInteger(),
|
|
||||||
toDate: dateRange.end.toUnixInteger(),
|
|
||||||
start: offset + 1, // finicity uses 1-based indexing
|
|
||||||
limit: count,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
maxRetries: 3,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return transactions
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return accountTransactions.flat()
|
|
||||||
}
|
|
||||||
|
|
||||||
private _loadTransactions(
|
|
||||||
connection: Connection,
|
|
||||||
{
|
|
||||||
transactions,
|
|
||||||
transactionsDateRange,
|
|
||||||
}: Pick<FinicityData, 'transactions' | 'transactionsDateRange'>
|
|
||||||
) {
|
|
||||||
if (!transactions.length) return []
|
|
||||||
|
|
||||||
const txnUpsertQueries = _.chunk(transactions, 1_000).map((chunk) => {
|
|
||||||
return this.prisma.$executeRaw`
|
|
||||||
INSERT INTO transaction (account_id, finicity_transaction_id, date, name, amount, pending, currency_code, merchant_name, finicity_type, finicity_categorization)
|
|
||||||
VALUES
|
|
||||||
${Prisma.join(
|
|
||||||
chunk.map((finicityTransaction) => {
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
accountId,
|
|
||||||
description,
|
|
||||||
memo,
|
|
||||||
amount,
|
|
||||||
status,
|
|
||||||
type,
|
|
||||||
categorization,
|
|
||||||
transactionDate,
|
|
||||||
postedDate,
|
|
||||||
currencySymbol,
|
|
||||||
} = finicityTransaction
|
|
||||||
|
|
||||||
return Prisma.sql`(
|
|
||||||
(SELECT id FROM account WHERE account_connection_id = ${
|
|
||||||
connection.id
|
|
||||||
} AND finicity_account_id = ${accountId.toString()}),
|
|
||||||
${id},
|
|
||||||
${DateTime.fromSeconds(transactionDate ?? postedDate, {
|
|
||||||
zone: 'utc',
|
|
||||||
}).toISODate()}::date,
|
|
||||||
${[description, memo].filter(Boolean).join(' ')},
|
|
||||||
${DbUtil.toDecimal(-amount)},
|
|
||||||
${status === 'pending'},
|
|
||||||
${currencySymbol || 'USD'},
|
|
||||||
${categorization?.normalizedPayeeName},
|
|
||||||
${type},
|
|
||||||
${categorization}
|
|
||||||
)`
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
ON CONFLICT (finicity_transaction_id) DO UPDATE
|
|
||||||
SET
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
amount = EXCLUDED.amount,
|
|
||||||
pending = EXCLUDED.pending,
|
|
||||||
merchant_name = EXCLUDED.merchant_name,
|
|
||||||
finicity_type = EXCLUDED.finicity_type,
|
|
||||||
finicity_categorization = EXCLUDED.finicity_categorization;
|
|
||||||
`
|
|
||||||
})
|
|
||||||
|
|
||||||
return [
|
|
||||||
// upsert transactions
|
|
||||||
...txnUpsertQueries,
|
|
||||||
// delete finicity-specific transactions that are no longer in finicity
|
|
||||||
this.prisma.transaction.deleteMany({
|
|
||||||
where: {
|
|
||||||
account: {
|
|
||||||
accountConnectionId: connection.id,
|
|
||||||
},
|
|
||||||
AND: [
|
|
||||||
{ finicityTransactionId: { not: null } },
|
|
||||||
{ finicityTransactionId: { notIn: transactions.map((t) => `${t.id}`) } },
|
|
||||||
],
|
|
||||||
date: {
|
|
||||||
gte: transactionsDateRange.start.startOf('day').toJSDate(),
|
|
||||||
lte: transactionsDateRange.end.endOf('day').toJSDate(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
private _loadInvestmentTransactions(
|
|
||||||
connection: Connection,
|
|
||||||
{
|
|
||||||
investmentTransactions,
|
|
||||||
investmentTransactionsDateRange,
|
|
||||||
}: Pick<FinicityData, 'investmentTransactions' | 'investmentTransactionsDateRange'>
|
|
||||||
) {
|
|
||||||
if (!investmentTransactions.length) return []
|
|
||||||
|
|
||||||
const securities = _(investmentTransactions)
|
|
||||||
.map((p) => p.security)
|
|
||||||
.filter(SharedUtil.nonNull)
|
|
||||||
.uniqBy((s) => getSecurityId(s))
|
|
||||||
.value()
|
|
||||||
|
|
||||||
return [
|
|
||||||
// upsert securities
|
|
||||||
...(securities.length > 0
|
|
||||||
? [
|
|
||||||
this.prisma.$executeRaw`
|
|
||||||
INSERT INTO security (finicity_security_id, finicity_security_id_type, symbol)
|
|
||||||
VALUES
|
|
||||||
${Prisma.join(
|
|
||||||
securities.map((s) => {
|
|
||||||
return Prisma.sql`(
|
|
||||||
${s.securityId},
|
|
||||||
${s.securityIdType},
|
|
||||||
${s.symbol}
|
|
||||||
)`
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
`,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
|
|
||||||
// upsert investment transactions
|
|
||||||
..._.chunk(investmentTransactions, 1_000).map((chunk) => {
|
|
||||||
return this.prisma.$executeRaw`
|
|
||||||
INSERT INTO investment_transaction (account_id, security_id, finicity_transaction_id, date, name, amount, fees, quantity, price, currency_code, finicity_investment_transaction_type)
|
|
||||||
VALUES
|
|
||||||
${Prisma.join(
|
|
||||||
chunk.map((t) => {
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
accountId,
|
|
||||||
amount,
|
|
||||||
feeAmount,
|
|
||||||
description,
|
|
||||||
memo,
|
|
||||||
unitQuantity,
|
|
||||||
unitPrice,
|
|
||||||
transactionDate,
|
|
||||||
postedDate,
|
|
||||||
currencySymbol,
|
|
||||||
investmentTransactionType,
|
|
||||||
security,
|
|
||||||
} = t
|
|
||||||
|
|
||||||
return Prisma.sql`(
|
|
||||||
(SELECT id FROM account WHERE account_connection_id = ${
|
|
||||||
connection.id
|
|
||||||
} AND finicity_account_id = ${accountId.toString()}),
|
|
||||||
${
|
|
||||||
security
|
|
||||||
? Prisma.sql`(SELECT id FROM security WHERE finicity_security_id = ${security.securityId} AND finicity_security_id_type = ${security.securityIdType})`
|
|
||||||
: null
|
|
||||||
},
|
|
||||||
${id},
|
|
||||||
${DateTime.fromSeconds(transactionDate ?? postedDate, {
|
|
||||||
zone: 'utc',
|
|
||||||
}).toISODate()}::date,
|
|
||||||
${[description, memo].filter(Boolean).join(' ')},
|
|
||||||
${DbUtil.toDecimal(-amount)},
|
|
||||||
${DbUtil.toDecimal(feeAmount)},
|
|
||||||
${DbUtil.toDecimal(unitQuantity ?? 0)},
|
|
||||||
${DbUtil.toDecimal(unitPrice ?? 0)},
|
|
||||||
${currencySymbol || 'USD'},
|
|
||||||
${investmentTransactionType}
|
|
||||||
)`
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
ON CONFLICT (finicity_transaction_id) DO UPDATE
|
|
||||||
SET
|
|
||||||
account_id = EXCLUDED.account_id,
|
|
||||||
security_id = EXCLUDED.security_id,
|
|
||||||
date = EXCLUDED.date,
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
amount = EXCLUDED.amount,
|
|
||||||
fees = EXCLUDED.fees,
|
|
||||||
quantity = EXCLUDED.quantity,
|
|
||||||
price = EXCLUDED.price,
|
|
||||||
currency_code = EXCLUDED.currency_code,
|
|
||||||
finicity_investment_transaction_type = EXCLUDED.finicity_investment_transaction_type;
|
|
||||||
`
|
|
||||||
}),
|
|
||||||
|
|
||||||
// delete finicity-specific investment transactions that are no longer in finicity
|
|
||||||
this.prisma.investmentTransaction.deleteMany({
|
|
||||||
where: {
|
|
||||||
account: {
|
|
||||||
accountConnectionId: connection.id,
|
|
||||||
},
|
|
||||||
AND: [
|
|
||||||
{ finicityTransactionId: { not: null } },
|
|
||||||
{
|
|
||||||
finicityTransactionId: {
|
|
||||||
notIn: investmentTransactions.map((t) => `${t.id}`),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
date: {
|
|
||||||
gte: investmentTransactionsDateRange.start.startOf('day').toJSDate(),
|
|
||||||
lte: investmentTransactionsDateRange.end.endOf('day').toJSDate(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,281 +0,0 @@
|
||||||
import type { Logger } from 'winston'
|
|
||||||
import type { AccountConnection, PrismaClient, User } from '@prisma/client'
|
|
||||||
import type { IETL, SyncConnectionOptions } from '@maybe-finance/server/shared'
|
|
||||||
import type { FinicityApi, FinicityTypes } from '@maybe-finance/finicity-api'
|
|
||||||
import type { IInstitutionProvider } from '../../institution'
|
|
||||||
import type {
|
|
||||||
AccountConnectionSyncEvent,
|
|
||||||
IAccountConnectionProvider,
|
|
||||||
} from '../../account-connection'
|
|
||||||
import _ from 'lodash'
|
|
||||||
import axios from 'axios'
|
|
||||||
import { v4 as uuid } from 'uuid'
|
|
||||||
import { SharedUtil } from '@maybe-finance/shared'
|
|
||||||
import { etl } from '@maybe-finance/server/shared'
|
|
||||||
|
|
||||||
export interface IFinicityConnect {
|
|
||||||
generateConnectUrl(userId: User['id'], institutionId: string): Promise<{ link: string }>
|
|
||||||
|
|
||||||
generateFixConnectUrl(
|
|
||||||
userId: User['id'],
|
|
||||||
accountConnectionId: AccountConnection['id']
|
|
||||||
): Promise<{ link: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FinicityService
|
|
||||||
implements IFinicityConnect, IAccountConnectionProvider, IInstitutionProvider
|
|
||||||
{
|
|
||||||
constructor(
|
|
||||||
private readonly logger: Logger,
|
|
||||||
private readonly prisma: PrismaClient,
|
|
||||||
private readonly finicity: FinicityApi,
|
|
||||||
private readonly etl: IETL<AccountConnection>,
|
|
||||||
private readonly webhookUrl: string | Promise<string>,
|
|
||||||
private readonly testMode: boolean
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async generateConnectUrl(userId: User['id'], institutionId: string) {
|
|
||||||
const customerId = await this.getOrCreateCustomerId(userId)
|
|
||||||
|
|
||||||
this.logger.debug(
|
|
||||||
`Generating Finicity connect URL with user=${userId} institution=${institutionId} customerId=${customerId}`
|
|
||||||
)
|
|
||||||
|
|
||||||
const res = await this.finicity.generateLiteConnectUrl({
|
|
||||||
customerId,
|
|
||||||
institutionId,
|
|
||||||
webhook: await this.webhookUrl,
|
|
||||||
webhookContentType: 'application/json',
|
|
||||||
})
|
|
||||||
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
async generateFixConnectUrl(userId: User['id'], accountConnectionId: AccountConnection['id']) {
|
|
||||||
const accountConnection = await this.prisma.accountConnection.findUniqueOrThrow({
|
|
||||||
where: { id: accountConnectionId },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!accountConnection.finicityInstitutionLoginId) {
|
|
||||||
throw new Error(
|
|
||||||
`connection ${accountConnection.id} is missing finicityInstitutionLoginId`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await this.finicity.generateFixConnectUrl({
|
|
||||||
customerId: await this.getOrCreateCustomerId(userId),
|
|
||||||
institutionLoginId: accountConnection.finicityInstitutionLoginId,
|
|
||||||
webhook: await this.webhookUrl,
|
|
||||||
webhookContentType: 'application/json',
|
|
||||||
})
|
|
||||||
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
async sync(connection: AccountConnection, options?: SyncConnectionOptions): Promise<void> {
|
|
||||||
if (options && options.type !== 'finicity') throw new Error('invalid sync options')
|
|
||||||
|
|
||||||
if (options?.initialSync) {
|
|
||||||
const user = await this.prisma.user.findUniqueOrThrow({
|
|
||||||
where: { id: connection.userId },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!user.finicityCustomerId) {
|
|
||||||
throw new Error(`user ${user.id} missing finicityCustomerId`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// refresh customer accounts
|
|
||||||
try {
|
|
||||||
this.logger.info(
|
|
||||||
`refreshing customer accounts for customer: ${user.finicityCustomerId}`
|
|
||||||
)
|
|
||||||
const { accounts } = await this.finicity.refreshCustomerAccounts({
|
|
||||||
customerId: user.finicityCustomerId,
|
|
||||||
})
|
|
||||||
|
|
||||||
// no need to await this - this is fire-and-forget and shouldn't delay the sync process
|
|
||||||
this.logger.info(
|
|
||||||
`triggering load historic transactions for customer: ${
|
|
||||||
user.finicityCustomerId
|
|
||||||
} accounts: ${accounts.map((a) => a.id)}`
|
|
||||||
)
|
|
||||||
Promise.allSettled(
|
|
||||||
accounts
|
|
||||||
.filter(
|
|
||||||
(a) =>
|
|
||||||
a.institutionLoginId.toString() ===
|
|
||||||
connection.finicityInstitutionLoginId
|
|
||||||
)
|
|
||||||
.map((account) =>
|
|
||||||
this.finicity
|
|
||||||
.loadHistoricTransactions({
|
|
||||||
accountId: account.id,
|
|
||||||
customerId: account.customerId,
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
this.logger.warn(
|
|
||||||
`error loading historic transactions for finicity account: ${account.id} customer: ${account.customerId}`,
|
|
||||||
err
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
// gracefully handle error, this shouldn't prevent the sync process from continuing
|
|
||||||
this.logger.error(`error refreshing customer accounts for initial sync`, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
finicityError:
|
|
||||||
axios.isAxiosError(error) && error.response
|
|
||||||
? _.pick(error.response, ['status', 'data'])
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(connection: AccountConnection): Promise<void> {
|
|
||||||
if (connection.finicityInstitutionLoginId) {
|
|
||||||
const user = await this.prisma.user.findUniqueOrThrow({
|
|
||||||
where: { id: connection.userId },
|
|
||||||
select: {
|
|
||||||
finicityCustomerId: true,
|
|
||||||
accountConnections: {
|
|
||||||
where: {
|
|
||||||
id: { not: connection.id },
|
|
||||||
finicityInstitutionLoginId: connection.finicityInstitutionLoginId,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// ensure there are no other connections with the same `finicityInstitutionLoginId` before deleting the accounts from Finicity
|
|
||||||
if (user.finicityCustomerId && !user.accountConnections.length) {
|
|
||||||
try {
|
|
||||||
await this.finicity.deleteCustomerAccountsByInstitutionLogin({
|
|
||||||
customerId: user.finicityCustomerId,
|
|
||||||
institutionLoginId: +connection.finicityInstitutionLoginId,
|
|
||||||
})
|
|
||||||
this.logger.info(
|
|
||||||
`deleted finicity customer ${user.finicityCustomerId} accounts for institutionLoginId ${connection.finicityInstitutionLoginId}`
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
`error deleting finicity customer ${user.finicityCustomerId} accounts for institutionLoginId ${connection.finicityInstitutionLoginId}`,
|
|
||||||
err
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.logger.warn(
|
|
||||||
`skipping delete for finicity customer ${user.finicityCustomerId} accounts for institutionLoginId ${connection.finicityInstitutionLoginId} (duplicate_connections: ${user.accountConnections.length})`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getInstitutions() {
|
|
||||||
const finicityInstitutions = await SharedUtil.paginate({
|
|
||||||
pageSize: 1000,
|
|
||||||
fetchData: (offset, count) =>
|
|
||||||
SharedUtil.withRetry(
|
|
||||||
() =>
|
|
||||||
this.finicity
|
|
||||||
.getInstitutions({
|
|
||||||
start: offset / count + 1,
|
|
||||||
limit: count,
|
|
||||||
})
|
|
||||||
.then(({ institutions, found, displaying }) => {
|
|
||||||
this.logger.debug(
|
|
||||||
`paginated finicity fetch inst=${displaying} (total=${found} offset=${offset} count=${count})`
|
|
||||||
)
|
|
||||||
return institutions
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
maxRetries: 3,
|
|
||||||
onError: (error, attempt) => {
|
|
||||||
this.logger.error(
|
|
||||||
`Finicity fetch institutions request failed attempt=${attempt} offset=${offset} count=${count}`,
|
|
||||||
{ error }
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
!axios.isAxiosError(error) ||
|
|
||||||
(error.response && error.response.status >= 500)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
return _.uniqBy(finicityInstitutions, (i) => i.id).map((finicityInstitution) => ({
|
|
||||||
providerId: `${finicityInstitution.id}`,
|
|
||||||
name: finicityInstitution.name || '',
|
|
||||||
url: finicityInstitution.urlHomeApp
|
|
||||||
? SharedUtil.normalizeUrl(finicityInstitution.urlHomeApp)
|
|
||||||
: null,
|
|
||||||
logoUrl: finicityInstitution.branding?.icon,
|
|
||||||
primaryColor: finicityInstitution.branding?.primaryColor,
|
|
||||||
oauth: finicityInstitution.oauthEnabled,
|
|
||||||
data: finicityInstitution,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getOrCreateCustomerId(
|
|
||||||
userId: User['id']
|
|
||||||
): Promise<FinicityTypes.AddCustomerResponse['id']> {
|
|
||||||
const user = await this.prisma.user.findUniqueOrThrow({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { id: true, finicityCustomerId: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (user.finicityCustomerId) {
|
|
||||||
return user.finicityCustomerId
|
|
||||||
}
|
|
||||||
|
|
||||||
// See https://api-reference.finicity.com/#/rest/api-endpoints/customer/add-customer
|
|
||||||
const finicityUsername = uuid()
|
|
||||||
|
|
||||||
const { id: finicityCustomerId } = this.testMode
|
|
||||||
? await this.finicity.addTestingCustomer({ username: finicityUsername })
|
|
||||||
: await this.finicity.addCustomer({ username: finicityUsername })
|
|
||||||
|
|
||||||
await this.prisma.user.update({
|
|
||||||
where: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
finicityUsername,
|
|
||||||
finicityCustomerId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
this.logger.info(
|
|
||||||
`created finicity customer ${finicityCustomerId} for user ${userId} (testMode=${this.testMode})`
|
|
||||||
)
|
|
||||||
return finicityCustomerId
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,144 +0,0 @@
|
||||||
import type { Logger } from 'winston'
|
|
||||||
import _ from 'lodash'
|
|
||||||
import type { PrismaClient } from '@prisma/client'
|
|
||||||
import type { FinicityApi, FinicityTypes } from '@maybe-finance/finicity-api'
|
|
||||||
import type { IAccountConnectionService } from '../../account-connection'
|
|
||||||
|
|
||||||
export interface IFinicityWebhookHandler {
|
|
||||||
handleWebhook(data: FinicityTypes.WebhookData): Promise<void>
|
|
||||||
handleTxPushEvent(event: FinicityTypes.TxPushEvent): Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FinicityWebhookHandler implements IFinicityWebhookHandler {
|
|
||||||
constructor(
|
|
||||||
private readonly logger: Logger,
|
|
||||||
private readonly prisma: PrismaClient,
|
|
||||||
private readonly finicity: FinicityApi,
|
|
||||||
private readonly accountConnectionService: IAccountConnectionService,
|
|
||||||
private readonly txPushUrl: string | Promise<string>
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process Finicity Connect webhooks. These handlers should execute as quick as possible and
|
|
||||||
* long-running operations should be performed in the background.
|
|
||||||
*/
|
|
||||||
async handleWebhook(data: FinicityTypes.WebhookData) {
|
|
||||||
switch (data.eventType) {
|
|
||||||
case 'added': {
|
|
||||||
const { accounts, institutionId } = data.payload
|
|
||||||
|
|
||||||
const { customerId, institutionLoginId } = accounts[0]
|
|
||||||
|
|
||||||
const [user, providerInstitution] = await Promise.all([
|
|
||||||
this.prisma.user.findUniqueOrThrow({
|
|
||||||
where: {
|
|
||||||
finicityCustomerId: customerId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.prisma.providerInstitution.findUnique({
|
|
||||||
where: {
|
|
||||||
provider_providerId: {
|
|
||||||
provider: 'FINICITY',
|
|
||||||
providerId: institutionId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
institution: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
const connection = await this.prisma.accountConnection.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name:
|
|
||||||
providerInstitution?.institution?.name ||
|
|
||||||
providerInstitution?.name ||
|
|
||||||
'Institution',
|
|
||||||
type: 'finicity',
|
|
||||||
finicityInstitutionId: institutionId,
|
|
||||||
finicityInstitutionLoginId: String(institutionLoginId),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await Promise.allSettled([
|
|
||||||
// subscribe to TxPUSH
|
|
||||||
...accounts.map(async (account) =>
|
|
||||||
this.finicity.subscribeTxPush({
|
|
||||||
accountId: account.id,
|
|
||||||
customerId: account.customerId,
|
|
||||||
callbackUrl: await this.txPushUrl,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
])
|
|
||||||
|
|
||||||
// sync
|
|
||||||
await this.accountConnectionService.sync(connection.id, {
|
|
||||||
type: 'finicity',
|
|
||||||
initialSync: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
this.logger.warn('Unhandled Finicity webhook', { data })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleTxPushEvent(event: FinicityTypes.TxPushEvent) {
|
|
||||||
switch (event.class) {
|
|
||||||
case 'account': {
|
|
||||||
const connections = await this.prisma.accountConnection.findMany({
|
|
||||||
where: {
|
|
||||||
accounts: {
|
|
||||||
some: {
|
|
||||||
finicityAccountId: {
|
|
||||||
in: _.uniq(event.records.map((a) => String(a.id))),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await Promise.allSettled(
|
|
||||||
connections.map((connection) =>
|
|
||||||
this.accountConnectionService.sync(connection.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'transaction': {
|
|
||||||
const connections = await this.prisma.accountConnection.findMany({
|
|
||||||
where: {
|
|
||||||
accounts: {
|
|
||||||
some: {
|
|
||||||
finicityAccountId: {
|
|
||||||
in: _.uniq(event.records.map((t) => String(t.accountId))),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await Promise.allSettled(
|
|
||||||
connections.map((connection) =>
|
|
||||||
this.accountConnectionService.sync(connection.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
this.logger.warn(`unhandled Finicity TxPush event`, { event })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
export * from './finicity.service'
|
|
||||||
export * from './finicity.etl'
|
|
||||||
export * from './finicity.webhook'
|
|
|
@ -1,5 +1,4 @@
|
||||||
export * from './plaid'
|
export * from './plaid'
|
||||||
export * from './finicity'
|
|
||||||
export * from './teller'
|
export * from './teller'
|
||||||
export * from './vehicle'
|
export * from './vehicle'
|
||||||
export * from './property'
|
export * from './property'
|
||||||
|
|
|
@ -13,8 +13,10 @@ import type {
|
||||||
Item as PlaidItem,
|
Item as PlaidItem,
|
||||||
LiabilitiesObject as PlaidLiabilities,
|
LiabilitiesObject as PlaidLiabilities,
|
||||||
PlaidApi,
|
PlaidApi,
|
||||||
|
PersonalFinanceCategory,
|
||||||
} from 'plaid'
|
} from 'plaid'
|
||||||
import { Prisma } from '@prisma/client'
|
import { InvestmentTransactionSubtype, InvestmentTransactionType } from 'plaid'
|
||||||
|
import { Prisma, InvestmentTransactionCategory } from '@prisma/client'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import _, { chunk } from 'lodash'
|
import _, { chunk } from 'lodash'
|
||||||
import { ErrorUtil, PlaidUtil } from '@maybe-finance/server/shared'
|
import { ErrorUtil, PlaidUtil } from '@maybe-finance/server/shared'
|
||||||
|
@ -366,7 +368,7 @@ export class PlaidETL implements IETL<Connection, PlaidRawData, PlaidData> {
|
||||||
|
|
||||||
const txnUpsertQueries = chunk(transactions, 1_000).map((chunk) => {
|
const txnUpsertQueries = chunk(transactions, 1_000).map((chunk) => {
|
||||||
return this.prisma.$executeRaw`
|
return this.prisma.$executeRaw`
|
||||||
INSERT INTO transaction (account_id, plaid_transaction_id, date, name, amount, pending, currency_code, merchant_name, plaid_category, plaid_category_id, plaid_personal_finance_category)
|
INSERT INTO transaction (account_id, plaid_transaction_id, date, name, amount, pending, currency_code, merchant_name, plaid_category, plaid_category_id, plaid_personal_finance_category, category)
|
||||||
VALUES
|
VALUES
|
||||||
${Prisma.join(
|
${Prisma.join(
|
||||||
chunk.map((plaidTransaction) => {
|
chunk.map((plaidTransaction) => {
|
||||||
|
@ -401,7 +403,8 @@ export class PlaidETL implements IETL<Connection, PlaidRawData, PlaidData> {
|
||||||
${merchant_name},
|
${merchant_name},
|
||||||
${category ?? []},
|
${category ?? []},
|
||||||
${category_id},
|
${category_id},
|
||||||
${personal_finance_category}
|
${personal_finance_category},
|
||||||
|
${this.getMaybeTransactionCategory(personal_finance_category)}
|
||||||
)`
|
)`
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
@ -414,6 +417,7 @@ export class PlaidETL implements IETL<Connection, PlaidRawData, PlaidData> {
|
||||||
plaid_category = EXCLUDED.plaid_category,
|
plaid_category = EXCLUDED.plaid_category,
|
||||||
plaid_category_id = EXCLUDED.plaid_category_id,
|
plaid_category_id = EXCLUDED.plaid_category_id,
|
||||||
plaid_personal_finance_category = EXCLUDED.plaid_personal_finance_category;
|
plaid_personal_finance_category = EXCLUDED.plaid_personal_finance_category;
|
||||||
|
category = EXCLUDED.category;
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -444,6 +448,68 @@ export class PlaidETL implements IETL<Connection, PlaidRawData, PlaidData> {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getMaybeTransactionCategory = (category?: PersonalFinanceCategory | null) => {
|
||||||
|
if (!category) {
|
||||||
|
return 'Other'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.primary === 'INCOME') {
|
||||||
|
return 'Income'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
['LOAN_PAYMENTS_MORTGAGE_PAYMENT', 'RENT_AND_UTILITIES_RENT'].includes(
|
||||||
|
category.detailed
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'Housing Payments'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.detailed === 'LOAN_PAYMENTS_CAR_PAYMENT') {
|
||||||
|
return 'Vehicle Payments'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.primary === 'LOAN_PAYMENTS') {
|
||||||
|
return 'Other Payments'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.primary === 'HOME_IMPROVEMENT') {
|
||||||
|
return 'Home Improvement'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.primary === 'GENERAL_MERCHANDISE') {
|
||||||
|
return 'Shopping'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
category.primary === 'RENT_AND_UTILITIES' &&
|
||||||
|
category.detailed !== 'RENT_AND_UTILITIES_RENT'
|
||||||
|
) {
|
||||||
|
return 'Utilities'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.primary === 'FOOD_AND_DRINK') {
|
||||||
|
return 'Food and Drink'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.primary === 'TRANSPORTATION') {
|
||||||
|
return 'Transportation'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.primary === 'TRAVEL') {
|
||||||
|
return 'Travel'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
['PERSONAL_CARE', 'MEDICAL'].includes(category.primary) &&
|
||||||
|
category.detailed !== 'MEDICAL_VETERINARY_SERVICES'
|
||||||
|
) {
|
||||||
|
return 'Health'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Other'
|
||||||
|
}
|
||||||
|
|
||||||
private _extractInvestmentTransactions(accessToken: string, dateRange: SharedType.DateRange) {
|
private _extractInvestmentTransactions(accessToken: string, dateRange: SharedType.DateRange) {
|
||||||
return SharedUtil.paginate({
|
return SharedUtil.paginate({
|
||||||
pageSize: 500, // https://plaid.com/docs/api/products/investments/#investments-transactions-get-request-options-count
|
pageSize: 500, // https://plaid.com/docs/api/products/investments/#investments-transactions-get-request-options-count
|
||||||
|
@ -548,7 +614,7 @@ export class PlaidETL implements IETL<Connection, PlaidRawData, PlaidData> {
|
||||||
...chunk(investmentTransactions, 1_000).map(
|
...chunk(investmentTransactions, 1_000).map(
|
||||||
(chunk) =>
|
(chunk) =>
|
||||||
this.prisma.$executeRaw`
|
this.prisma.$executeRaw`
|
||||||
INSERT INTO investment_transaction (account_id, security_id, plaid_investment_transaction_id, date, name, amount, fees, quantity, price, currency_code, plaid_type, plaid_subtype)
|
INSERT INTO investment_transaction (account_id, security_id, plaid_investment_transaction_id, date, name, amount, fees, quantity, price, currency_code, plaid_type, plaid_subtype, category)
|
||||||
VALUES
|
VALUES
|
||||||
${Prisma.join(
|
${Prisma.join(
|
||||||
chunk.map(
|
chunk.map(
|
||||||
|
@ -584,7 +650,11 @@ export class PlaidETL implements IETL<Connection, PlaidRawData, PlaidData> {
|
||||||
${DbUtil.toDecimal(price)},
|
${DbUtil.toDecimal(price)},
|
||||||
${currencyCode},
|
${currencyCode},
|
||||||
${type},
|
${type},
|
||||||
${subtype}
|
${subtype},
|
||||||
|
${this.getInvestmentTransactionCategoryByPlaidType(
|
||||||
|
type,
|
||||||
|
subtype
|
||||||
|
)}
|
||||||
)`
|
)`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -602,6 +672,7 @@ export class PlaidETL implements IETL<Connection, PlaidRawData, PlaidData> {
|
||||||
currency_code = EXCLUDED.currency_code,
|
currency_code = EXCLUDED.currency_code,
|
||||||
plaid_type = EXCLUDED.plaid_type,
|
plaid_type = EXCLUDED.plaid_type,
|
||||||
plaid_subtype = EXCLUDED.plaid_subtype;
|
plaid_subtype = EXCLUDED.plaid_subtype;
|
||||||
|
category = EXCLUDED.category;
|
||||||
`
|
`
|
||||||
),
|
),
|
||||||
|
|
||||||
|
@ -669,6 +740,63 @@ export class PlaidETL implements IETL<Connection, PlaidRawData, PlaidData> {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getInvestmentTransactionCategoryByPlaidType = (
|
||||||
|
type: InvestmentTransactionType,
|
||||||
|
subType: InvestmentTransactionSubtype
|
||||||
|
): InvestmentTransactionCategory => {
|
||||||
|
if (type === InvestmentTransactionType.Buy) {
|
||||||
|
return InvestmentTransactionCategory.buy
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === InvestmentTransactionType.Sell) {
|
||||||
|
return InvestmentTransactionCategory.sell
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
InvestmentTransactionSubtype.Dividend,
|
||||||
|
InvestmentTransactionSubtype.QualifiedDividend,
|
||||||
|
InvestmentTransactionSubtype.NonQualifiedDividend,
|
||||||
|
].includes(subType)
|
||||||
|
) {
|
||||||
|
return InvestmentTransactionCategory.dividend
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
InvestmentTransactionSubtype.NonResidentTax,
|
||||||
|
InvestmentTransactionSubtype.Tax,
|
||||||
|
InvestmentTransactionSubtype.TaxWithheld,
|
||||||
|
].includes(subType)
|
||||||
|
) {
|
||||||
|
return InvestmentTransactionCategory.tax
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
type === InvestmentTransactionType.Fee ||
|
||||||
|
[
|
||||||
|
InvestmentTransactionSubtype.AccountFee,
|
||||||
|
InvestmentTransactionSubtype.LegalFee,
|
||||||
|
InvestmentTransactionSubtype.ManagementFee,
|
||||||
|
InvestmentTransactionSubtype.MarginExpense,
|
||||||
|
InvestmentTransactionSubtype.TransferFee,
|
||||||
|
InvestmentTransactionSubtype.TrustFee,
|
||||||
|
].includes(subType)
|
||||||
|
) {
|
||||||
|
return InvestmentTransactionCategory.fee
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === InvestmentTransactionType.Cash) {
|
||||||
|
return InvestmentTransactionCategory.transfer
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === InvestmentTransactionType.Cancel) {
|
||||||
|
return InvestmentTransactionCategory.cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
return InvestmentTransactionCategory.other
|
||||||
|
}
|
||||||
|
|
||||||
private async _extractHoldings(accessToken: string) {
|
private async _extractHoldings(accessToken: string) {
|
||||||
try {
|
try {
|
||||||
const { data } = await this.plaid.investmentsHoldingsGet({ access_token: accessToken })
|
const { data } = await this.plaid.investmentsHoldingsGet({ access_token: accessToken })
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { AccountConnection, PrismaClient } from '@prisma/client'
|
import type { AccountConnection, PrismaClient } from '@prisma/client'
|
||||||
|
import { AccountClassification } from '@prisma/client'
|
||||||
import type { Logger } from 'winston'
|
import type { Logger } from 'winston'
|
||||||
import { AccountUtil, SharedUtil, type SharedType } from '@maybe-finance/shared'
|
import { AccountUtil, SharedUtil, type SharedType } from '@maybe-finance/shared'
|
||||||
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
|
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
|
||||||
|
@ -24,6 +25,40 @@ type Connection = Pick<
|
||||||
'id' | 'userId' | 'tellerInstitutionId' | 'tellerAccessToken'
|
'id' | 'userId' | 'tellerInstitutionId' | 'tellerAccessToken'
|
||||||
>
|
>
|
||||||
|
|
||||||
|
const maybeCategoryByTellerCategory: Record<
|
||||||
|
Required<TellerTypes.Transaction['details']>['category'],
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
accommodation: 'Travel',
|
||||||
|
advertising: 'Other',
|
||||||
|
bar: 'Food and Drink',
|
||||||
|
charity: 'Other',
|
||||||
|
clothing: 'Shopping',
|
||||||
|
dining: 'Food and Drink',
|
||||||
|
education: 'Other',
|
||||||
|
electronics: 'Shopping',
|
||||||
|
entertainment: 'Shopping',
|
||||||
|
fuel: 'Transportation',
|
||||||
|
general: 'Other',
|
||||||
|
groceries: 'Food and Drink',
|
||||||
|
health: 'Health',
|
||||||
|
home: 'Home Improvement',
|
||||||
|
income: 'Income',
|
||||||
|
insurance: 'Other',
|
||||||
|
investment: 'Other',
|
||||||
|
loan: 'Other',
|
||||||
|
office: 'Other',
|
||||||
|
phone: 'Utilities',
|
||||||
|
service: 'Other',
|
||||||
|
shopping: 'Shopping',
|
||||||
|
software: 'Shopping',
|
||||||
|
sport: 'Shopping',
|
||||||
|
tax: 'Other',
|
||||||
|
transport: 'Transportation',
|
||||||
|
transportation: 'Transportation',
|
||||||
|
utilities: 'Utilities',
|
||||||
|
}
|
||||||
|
|
||||||
export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
|
@ -164,6 +199,12 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
private async _extractTransactions(accessToken: string, accountIds: string[]) {
|
private async _extractTransactions(accessToken: string, accountIds: string[]) {
|
||||||
const accountTransactions = await Promise.all(
|
const accountTransactions = await Promise.all(
|
||||||
accountIds.map(async (accountId) => {
|
accountIds.map(async (accountId) => {
|
||||||
|
const account = await this.prisma.account.findFirst({
|
||||||
|
where: {
|
||||||
|
tellerAccountId: accountId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const transactions = await SharedUtil.withRetry(
|
const transactions = await SharedUtil.withRetry(
|
||||||
() =>
|
() =>
|
||||||
this.teller.getTransactions({
|
this.teller.getTransactions({
|
||||||
|
@ -174,6 +215,11 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if (account!.classification === AccountClassification.asset) {
|
||||||
|
transactions.forEach((t) => {
|
||||||
|
t.amount = String(Number(t.amount) * -1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return transactions
|
return transactions
|
||||||
})
|
})
|
||||||
|
@ -192,7 +238,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
|
|
||||||
const txnUpsertQueries = _.chunk(transactions, 1_000).map((chunk) => {
|
const txnUpsertQueries = _.chunk(transactions, 1_000).map((chunk) => {
|
||||||
return this.prisma.$executeRaw`
|
return this.prisma.$executeRaw`
|
||||||
INSERT INTO transaction (account_id, teller_transaction_id, date, name, amount, pending, currency_code, merchant_name, teller_type, teller_category)
|
INSERT INTO transaction (account_id, teller_transaction_id, date, name, amount, pending, currency_code, merchant_name, teller_type, teller_category, category)
|
||||||
VALUES
|
VALUES
|
||||||
${Prisma.join(
|
${Prisma.join(
|
||||||
chunk.map((tellerTransaction) => {
|
chunk.map((tellerTransaction) => {
|
||||||
|
@ -219,7 +265,8 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
${'USD'},
|
${'USD'},
|
||||||
${details.counterparty?.name ?? ''},
|
${details.counterparty?.name ?? ''},
|
||||||
${type},
|
${type},
|
||||||
${details.category ?? ''}
|
${details.category ?? ''},
|
||||||
|
${maybeCategoryByTellerCategory[details.category ?? ''] ?? 'Other'}
|
||||||
)`
|
)`
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
@ -231,6 +278,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
merchant_name = EXCLUDED.merchant_name,
|
merchant_name = EXCLUDED.merchant_name,
|
||||||
teller_type = EXCLUDED.teller_type,
|
teller_type = EXCLUDED.teller_type,
|
||||||
teller_category = EXCLUDED.teller_category;
|
teller_category = EXCLUDED.teller_category;
|
||||||
|
category = EXCLUDED.category;
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -482,7 +482,6 @@ export function getPolygonTicker({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finicity's `type` field isn't really helpful here, so we'll just use isOptionTicker
|
|
||||||
if (MarketUtil.isOptionTicker(symbol)) {
|
if (MarketUtil.isOptionTicker(symbol)) {
|
||||||
return new PolygonTicker('options', `O:${symbol}`)
|
return new PolygonTicker('options', `O:${symbol}`)
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,7 +42,6 @@ export type SyncConnectionOptions =
|
||||||
type: 'plaid'
|
type: 'plaid'
|
||||||
products?: Array<'transactions' | 'investment-transactions' | 'holdings' | 'liabilities'>
|
products?: Array<'transactions' | 'investment-transactions' | 'holdings' | 'liabilities'>
|
||||||
}
|
}
|
||||||
| { type: 'finicity'; initialSync?: boolean }
|
|
||||||
| { type: 'teller'; initialSync?: boolean }
|
| { type: 'teller'; initialSync?: boolean }
|
||||||
|
|
||||||
export type SyncConnectionQueueJobData = {
|
export type SyncConnectionQueueJobData = {
|
||||||
|
@ -72,7 +71,7 @@ export type SyncSecurityQueue = IQueue<SyncSecurityQueueJobData, 'sync-all-secur
|
||||||
export type PurgeUserQueue = IQueue<{ userId: User['id'] }, 'purge-user'>
|
export type PurgeUserQueue = IQueue<{ userId: User['id'] }, 'purge-user'>
|
||||||
export type SyncInstitutionQueue = IQueue<
|
export type SyncInstitutionQueue = IQueue<
|
||||||
{},
|
{},
|
||||||
'sync-finicity-institutions' | 'sync-plaid-institutions' | 'sync-teller-institutions'
|
'sync-plaid-institutions' | 'sync-teller-institutions'
|
||||||
>
|
>
|
||||||
export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>
|
export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>
|
||||||
|
|
||||||
|
|
|
@ -71,7 +71,6 @@ export class InMemoryQueueFactory implements IQueueFactory {
|
||||||
private readonly ignoreJobNames: string[] = [
|
private readonly ignoreJobNames: string[] = [
|
||||||
'sync-all-securities',
|
'sync-all-securities',
|
||||||
'sync-plaid-institutions',
|
'sync-plaid-institutions',
|
||||||
'sync-finicity-institutions',
|
|
||||||
'trial-reminders',
|
'trial-reminders',
|
||||||
'send-email',
|
'send-email',
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,121 +0,0 @@
|
||||||
import type { Account, AccountCategory, AccountClassification, AccountType } from '@prisma/client'
|
|
||||||
import { Prisma } from '@prisma/client'
|
|
||||||
import { Duration } from 'luxon'
|
|
||||||
import type { FinicityTypes } from '@maybe-finance/finicity-api'
|
|
||||||
|
|
||||||
type FinicityAccount = FinicityTypes.CustomerAccount
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finicity delivers up to 180 days prior to account addition but doesn't provide a cutoff window
|
|
||||||
*/
|
|
||||||
export const FINICITY_WINDOW_MAX = Duration.fromObject({ years: 2 })
|
|
||||||
|
|
||||||
export function getType({ type }: Pick<FinicityAccount, 'type'>): AccountType {
|
|
||||||
switch (type) {
|
|
||||||
case 'investment':
|
|
||||||
case 'investmentTaxDeferred':
|
|
||||||
case 'brokerageAccount':
|
|
||||||
case '401k':
|
|
||||||
case '401a':
|
|
||||||
case '403b':
|
|
||||||
case '457':
|
|
||||||
case '457plan':
|
|
||||||
case '529':
|
|
||||||
case '529plan':
|
|
||||||
case 'ira':
|
|
||||||
case 'simpleIRA':
|
|
||||||
case 'sepIRA':
|
|
||||||
case 'roth':
|
|
||||||
case 'roth401k':
|
|
||||||
case 'rollover':
|
|
||||||
case 'ugma':
|
|
||||||
case 'utma':
|
|
||||||
case 'keogh':
|
|
||||||
case 'employeeStockPurchasePlan':
|
|
||||||
return 'INVESTMENT'
|
|
||||||
case 'creditCard':
|
|
||||||
return 'CREDIT'
|
|
||||||
case 'lineOfCredit':
|
|
||||||
case 'loan':
|
|
||||||
case 'studentLoan':
|
|
||||||
case 'studentLoanAccount':
|
|
||||||
case 'studentLoanGroup':
|
|
||||||
case 'mortgage':
|
|
||||||
return 'LOAN'
|
|
||||||
default:
|
|
||||||
return 'DEPOSITORY'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAccountCategory({ type }: Pick<FinicityAccount, 'type'>): AccountCategory {
|
|
||||||
switch (type) {
|
|
||||||
case 'checking':
|
|
||||||
case 'savings':
|
|
||||||
case 'cd':
|
|
||||||
case 'moneyMarket':
|
|
||||||
return 'cash'
|
|
||||||
case 'investment':
|
|
||||||
case 'investmentTaxDeferred':
|
|
||||||
case 'brokerageAccount':
|
|
||||||
case '401k':
|
|
||||||
case '401a':
|
|
||||||
case '403b':
|
|
||||||
case '457':
|
|
||||||
case '457plan':
|
|
||||||
case '529':
|
|
||||||
case '529plan':
|
|
||||||
case 'ira':
|
|
||||||
case 'simpleIRA':
|
|
||||||
case 'sepIRA':
|
|
||||||
case 'roth':
|
|
||||||
case 'roth401k':
|
|
||||||
case 'rollover':
|
|
||||||
case 'ugma':
|
|
||||||
case 'utma':
|
|
||||||
case 'keogh':
|
|
||||||
case 'employeeStockPurchasePlan':
|
|
||||||
return 'investment'
|
|
||||||
case 'mortgage':
|
|
||||||
case 'loan':
|
|
||||||
case 'lineOfCredit':
|
|
||||||
case 'studentLoan':
|
|
||||||
case 'studentLoanAccount':
|
|
||||||
case 'studentLoanGroup':
|
|
||||||
return 'loan'
|
|
||||||
case 'creditCard':
|
|
||||||
return 'credit'
|
|
||||||
case 'cryptocurrency':
|
|
||||||
return 'crypto'
|
|
||||||
default:
|
|
||||||
return 'other'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAccountBalanceData(
|
|
||||||
{ balance, currency, detail }: Pick<FinicityAccount, 'balance' | 'currency' | 'detail'>,
|
|
||||||
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(balance ? sign * balance : 0),
|
|
||||||
currentBalanceStrategy: 'current',
|
|
||||||
availableBalanceProvider: !detail
|
|
||||||
? null
|
|
||||||
: detail.availableBalanceAmount != null
|
|
||||||
? new Prisma.Decimal(sign * detail.availableBalanceAmount)
|
|
||||||
: detail.availableCashBalance != null
|
|
||||||
? new Prisma.Decimal(sign * detail.availableCashBalance)
|
|
||||||
: null,
|
|
||||||
availableBalanceStrategy: 'available',
|
|
||||||
currencyCode: currency,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +1,5 @@
|
||||||
export * as AuthUtil from './auth-utils'
|
export * as AuthUtil from './auth-utils'
|
||||||
export * as DbUtil from './db-utils'
|
export * as DbUtil from './db-utils'
|
||||||
export * as FinicityUtil from './finicity-utils'
|
|
||||||
export * as PlaidUtil from './plaid-utils'
|
export * as PlaidUtil from './plaid-utils'
|
||||||
export * as TellerUtil from './teller-utils'
|
export * as TellerUtil from './teller-utils'
|
||||||
export * as ErrorUtil from './error-utils'
|
export * as ErrorUtil from './error-utils'
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
import {
|
import { Prisma, AccountCategory, AccountType } from '@prisma/client'
|
||||||
Prisma,
|
import type { AccountClassification } from '@prisma/client'
|
||||||
AccountCategory,
|
import type { Account } from '@prisma/client'
|
||||||
AccountType,
|
|
||||||
type Account,
|
|
||||||
type AccountClassification,
|
|
||||||
} from '@prisma/client'
|
|
||||||
import type { TellerTypes } from '@maybe-finance/teller-api'
|
import type { TellerTypes } from '@maybe-finance/teller-api'
|
||||||
import { Duration } from 'luxon'
|
import { Duration } from 'luxon'
|
||||||
|
|
||||||
|
|
|
@ -12,13 +12,7 @@ export type { Transaction }
|
||||||
|
|
||||||
export type TransactionEnriched = Omit<
|
export type TransactionEnriched = Omit<
|
||||||
Transaction,
|
Transaction,
|
||||||
| 'plaidTransactionId'
|
'plaidTransactionId' | 'plaidCategory' | 'plaidCategoryId' | 'plaidPersonalFinanceCategory'
|
||||||
| 'plaidCategory'
|
|
||||||
| 'plaidCategoryId'
|
|
||||||
| 'plaidPersonalFinanceCategory'
|
|
||||||
| 'finicityTransactionId'
|
|
||||||
| 'finicityType'
|
|
||||||
| 'finicityCategorization'
|
|
||||||
> & {
|
> & {
|
||||||
type: TransactionType
|
type: TransactionType
|
||||||
userId: User['id']
|
userId: User['id']
|
||||||
|
|
|
@ -39,7 +39,6 @@
|
||||||
"@casl/ability": "^6.3.2",
|
"@casl/ability": "^6.3.2",
|
||||||
"@casl/prisma": "^1.4.1",
|
"@casl/prisma": "^1.4.1",
|
||||||
"@fast-csv/format": "^4.3.5",
|
"@fast-csv/format": "^4.3.5",
|
||||||
"@finicity/connect-web-sdk": "^1.0.0-rc.4",
|
|
||||||
"@headlessui/react": "^1.7.2",
|
"@headlessui/react": "^1.7.2",
|
||||||
"@hookform/resolvers": "^2.9.6",
|
"@hookform/resolvers": "^2.9.6",
|
||||||
"@polygon.io/client-js": "^6.0.6",
|
"@polygon.io/client-js": "^6.0.6",
|
||||||
|
@ -154,7 +153,7 @@
|
||||||
"smooth-scroll-into-view-if-needed": "^1.1.33",
|
"smooth-scroll-into-view-if-needed": "^1.1.33",
|
||||||
"stripe": "^10.17.0",
|
"stripe": "^10.17.0",
|
||||||
"superjson": "^1.11.0",
|
"superjson": "^1.11.0",
|
||||||
"tailwindcss": "3.2.4",
|
"tailwindcss": "^3.4.1",
|
||||||
"teller-connect-react": "^0.1.0",
|
"teller-connect-react": "^0.1.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
|
@ -188,9 +187,8 @@
|
||||||
"@storybook/manager-webpack5": "6.5.15",
|
"@storybook/manager-webpack5": "6.5.15",
|
||||||
"@storybook/react": "6.5.15",
|
"@storybook/react": "6.5.15",
|
||||||
"@svgr/webpack": "^6.1.2",
|
"@svgr/webpack": "^6.1.2",
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@tailwindcss/line-clamp": "^0.4.2",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@tailwindcss/typography": "^0.5.8",
|
|
||||||
"@testing-library/jest-dom": "^5.16.2",
|
"@testing-library/jest-dom": "^5.16.2",
|
||||||
"@testing-library/react": "13.4.0",
|
"@testing-library/react": "13.4.0",
|
||||||
"@testing-library/user-event": "^13.2.1",
|
"@testing-library/user-event": "^13.2.1",
|
||||||
|
|
|
@ -0,0 +1,79 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "investment_transaction"
|
||||||
|
RENAME COLUMN "category" TO "category_old";
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS holdings_enriched;
|
||||||
|
|
||||||
|
ALTER TABLE "investment_transaction"
|
||||||
|
ADD COLUMN "category" "InvestmentTransactionCategory" NOT NULL GENERATED ALWAYS AS (
|
||||||
|
CASE
|
||||||
|
WHEN "plaid_type" = 'buy' THEN 'buy'::"InvestmentTransactionCategory"
|
||||||
|
WHEN "plaid_type" = 'sell' THEN 'sell'::"InvestmentTransactionCategory"
|
||||||
|
WHEN "plaid_subtype" IN ('dividend', 'qualified dividend', 'non-qualified dividend') THEN 'dividend'::"InvestmentTransactionCategory"
|
||||||
|
WHEN "plaid_subtype" IN ('non-resident tax', 'tax', 'tax withheld') THEN 'tax'::"InvestmentTransactionCategory"
|
||||||
|
WHEN "plaid_type" = 'fee' OR "plaid_subtype" IN ('account fee', 'legal fee', 'management fee', 'margin expense', 'transfer fee', 'trust fee') THEN 'fee'::"InvestmentTransactionCategory"
|
||||||
|
WHEN "plaid_type" = 'cash' THEN 'transfer'::"InvestmentTransactionCategory"
|
||||||
|
WHEN "plaid_type" = 'cancel' THEN 'cancel'::"InvestmentTransactionCategory"
|
||||||
|
|
||||||
|
ELSE 'other'::"InvestmentTransactionCategory"
|
||||||
|
END
|
||||||
|
) STORED;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW holdings_enriched AS (
|
||||||
|
SELECT
|
||||||
|
h.id,
|
||||||
|
h.account_id,
|
||||||
|
h.security_id,
|
||||||
|
h.quantity,
|
||||||
|
COALESCE(pricing_latest.price_close * h.quantity * COALESCE(s.shares_per_contract, 1), h.value) AS "value",
|
||||||
|
COALESCE(h.cost_basis, tcb.cost_basis * h.quantity) AS "cost_basis",
|
||||||
|
COALESCE(h.cost_basis / h.quantity / COALESCE(s.shares_per_contract, 1), tcb.cost_basis) AS "cost_basis_per_share",
|
||||||
|
pricing_latest.price_close AS "price",
|
||||||
|
pricing_prev.price_close AS "price_prev",
|
||||||
|
h.excluded
|
||||||
|
FROM
|
||||||
|
holding h
|
||||||
|
INNER JOIN security s ON s.id = h.security_id
|
||||||
|
-- latest security pricing
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
price_close
|
||||||
|
FROM
|
||||||
|
security_pricing
|
||||||
|
WHERE
|
||||||
|
security_id = h.security_id
|
||||||
|
ORDER BY
|
||||||
|
date DESC
|
||||||
|
LIMIT 1
|
||||||
|
) pricing_latest ON true
|
||||||
|
-- previous security pricing (for computing daily ∆)
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
price_close
|
||||||
|
FROM
|
||||||
|
security_pricing
|
||||||
|
WHERE
|
||||||
|
security_id = h.security_id
|
||||||
|
ORDER BY
|
||||||
|
date DESC
|
||||||
|
LIMIT 1
|
||||||
|
OFFSET 1
|
||||||
|
) pricing_prev ON true
|
||||||
|
-- calculate cost basis from transactions
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
it.account_id,
|
||||||
|
it.security_id,
|
||||||
|
SUM(it.quantity * it.price) / SUM(it.quantity) AS cost_basis
|
||||||
|
FROM
|
||||||
|
investment_transaction it
|
||||||
|
WHERE
|
||||||
|
it.plaid_type = 'buy'
|
||||||
|
AND it.quantity > 0
|
||||||
|
GROUP BY
|
||||||
|
it.account_id,
|
||||||
|
it.security_id
|
||||||
|
) tcb ON tcb.account_id = h.account_id AND tcb.security_id = s.id
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "investment_transaction" DROP COLUMN "category_old";
|
|
@ -0,0 +1,91 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "transaction"
|
||||||
|
RENAME COLUMN "category" TO "category_old";
|
||||||
|
ALTER TABLE "transaction"
|
||||||
|
RENAME COLUMN "category_user" TO "category_user_old";
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS transactions_enriched;
|
||||||
|
|
||||||
|
ALTER TABLE "transaction" ADD COLUMN "category_user" TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE "transaction"
|
||||||
|
ADD COLUMN "category" TEXT NOT NULL GENERATED ALWAYS AS (COALESCE(category_user,
|
||||||
|
CASE
|
||||||
|
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'INCOME'::text) THEN 'Income'::text
|
||||||
|
WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = ANY (ARRAY['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text])) THEN 'Housing Payments'::text
|
||||||
|
WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = 'LOAN_PAYMENTS_CAR_PAYMENT'::text) THEN 'Vehicle Payments'::text
|
||||||
|
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'LOAN_PAYMENTS'::text) THEN 'Other Payments'::text
|
||||||
|
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'HOME_IMPROVEMENT'::text) THEN 'Home Improvement'::text
|
||||||
|
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'GENERAL_MERCHANDISE'::text) THEN 'Shopping'::text
|
||||||
|
WHEN (((plaid_personal_finance_category ->> 'primary'::text) = 'RENT_AND_UTILITIES'::text) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'RENT_AND_UTILITIES_RENT'::text)) THEN 'Utilities'::text
|
||||||
|
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'FOOD_AND_DRINK'::text) THEN 'Food and Drink'::text
|
||||||
|
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRANSPORTATION'::text) THEN 'Transportation'::text
|
||||||
|
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRAVEL'::text) THEN 'Travel'::text
|
||||||
|
WHEN (((plaid_personal_finance_category ->> 'primary'::text) = ANY (ARRAY['PERSONAL_CARE'::text, 'MEDICAL'::text])) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'MEDICAL_VETERINARY_SERVICES'::text)) THEN 'Health'::text
|
||||||
|
WHEN (teller_category = 'income'::text) THEN 'Income'::text
|
||||||
|
WHEN (teller_category = 'home'::text) THEN 'Home Improvement'::text
|
||||||
|
WHEN (teller_category = ANY (ARRAY['phone'::text, 'utilities'::text])) THEN 'Utilities'::text
|
||||||
|
WHEN (teller_category = ANY (ARRAY['dining'::text, 'bar'::text, 'groceries'::text])) THEN 'Food and Drink'::text
|
||||||
|
WHEN (teller_category = ANY (ARRAY['clothing'::text, 'entertainment'::text, 'shopping'::text, 'electronics'::text, 'software'::text, 'sport'::text])) THEN 'Shopping'::text
|
||||||
|
WHEN (teller_category = ANY (ARRAY['transportation'::text, 'fuel'::text])) THEN 'Transportation'::text
|
||||||
|
WHEN (teller_category = ANY (ARRAY['accommodation'::text, 'transport'::text])) THEN 'Travel'::text
|
||||||
|
WHEN (teller_category = 'health'::text) THEN 'Health'::text
|
||||||
|
WHEN (teller_category = ANY (ARRAY['loan'::text, 'tax'::text, 'insurance'::text, 'office'::text])) THEN 'Other Payments'::text
|
||||||
|
ELSE 'Other'::text
|
||||||
|
END)) STORED;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW transactions_enriched AS (
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.created_at as "createdAt",
|
||||||
|
t.updated_at as "updatedAt",
|
||||||
|
t.name,
|
||||||
|
t.account_id as "accountId",
|
||||||
|
t.date,
|
||||||
|
t.flow,
|
||||||
|
COALESCE(
|
||||||
|
t.type_user,
|
||||||
|
CASE
|
||||||
|
-- no matching transaction
|
||||||
|
WHEN t.match_id IS NULL THEN (
|
||||||
|
CASE
|
||||||
|
t.flow
|
||||||
|
WHEN 'INFLOW' THEN (
|
||||||
|
CASE
|
||||||
|
a.classification
|
||||||
|
WHEN 'asset' THEN 'INCOME' :: "TransactionType"
|
||||||
|
WHEN 'liability' THEN 'PAYMENT' :: "TransactionType"
|
||||||
|
END
|
||||||
|
)
|
||||||
|
WHEN 'OUTFLOW' THEN 'EXPENSE' :: "TransactionType"
|
||||||
|
END
|
||||||
|
) -- has matching transaction
|
||||||
|
ELSE (
|
||||||
|
CASE
|
||||||
|
a.classification
|
||||||
|
WHEN 'asset' THEN 'TRANSFER' :: "TransactionType"
|
||||||
|
WHEN 'liability' THEN 'PAYMENT' :: "TransactionType"
|
||||||
|
END
|
||||||
|
)
|
||||||
|
END
|
||||||
|
) AS "type",
|
||||||
|
t.type_user as "typeUser",
|
||||||
|
t.amount,
|
||||||
|
t.currency_code as "currencyCode",
|
||||||
|
t.pending,
|
||||||
|
t.merchant_name as "merchantName",
|
||||||
|
t.category,
|
||||||
|
t.category_user as "categoryUser",
|
||||||
|
t.excluded,
|
||||||
|
t.match_id as "matchId",
|
||||||
|
COALESCE(ac.user_id, a.user_id) as "userId",
|
||||||
|
a.classification as "accountClassification",
|
||||||
|
a.type as "accountType"
|
||||||
|
FROM
|
||||||
|
transaction t
|
||||||
|
inner join account a on a.id = t.account_id
|
||||||
|
left join account_connection ac on a.account_connection_id = ac.id
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "transaction" DROP COLUMN "category_old";
|
||||||
|
ALTER TABLE "transaction" DROP COLUMN "category_user_old";
|
|
@ -0,0 +1,107 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The values [finicity] on the enum `AccountConnectionType` will be removed. If these variants are still used in the database, this will fail.
|
||||||
|
- The values [finicity] on the enum `AccountProvider` will be removed. If these variants are still used in the database, this will fail.
|
||||||
|
- The values [FINICITY] on the enum `Provider` will be removed. If these variants are still used in the database, this will fail.
|
||||||
|
- You are about to drop the column `finicity_account_id` on the `account` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_detail` on the `account` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_type` on the `account` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_error` on the `account_connection` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_institution_id` on the `account_connection` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_institution_login_id` on the `account_connection` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_position_id` on the `holding` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_investment_transaction_type` on the `investment_transaction` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_transaction_id` on the `investment_transaction` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_asset_class` on the `security` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_fi_asset_class` on the `security` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_security_id` on the `security` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_security_id_type` on the `security` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_type` on the `security` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_categorization` on the `transaction` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_transaction_id` on the `transaction` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_type` on the `transaction` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_customer_id` on the `user` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `finicity_username` on the `user` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterEnum
|
||||||
|
BEGIN;
|
||||||
|
CREATE TYPE "AccountConnectionType_new" AS ENUM ('plaid', 'teller');
|
||||||
|
ALTER TABLE "account_connection" ALTER COLUMN "type" TYPE "AccountConnectionType_new" USING ("type"::text::"AccountConnectionType_new");
|
||||||
|
ALTER TYPE "AccountConnectionType" RENAME TO "AccountConnectionType_old";
|
||||||
|
ALTER TYPE "AccountConnectionType_new" RENAME TO "AccountConnectionType";
|
||||||
|
DROP TYPE "AccountConnectionType_old";
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
BEGIN;
|
||||||
|
CREATE TYPE "AccountProvider_new" AS ENUM ('user', 'plaid', 'teller');
|
||||||
|
ALTER TABLE "account" ALTER COLUMN "provider" TYPE "AccountProvider_new" USING ("provider"::text::"AccountProvider_new");
|
||||||
|
ALTER TYPE "AccountProvider" RENAME TO "AccountProvider_old";
|
||||||
|
ALTER TYPE "AccountProvider_new" RENAME TO "AccountProvider";
|
||||||
|
DROP TYPE "AccountProvider_old";
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
BEGIN;
|
||||||
|
CREATE TYPE "Provider_new" AS ENUM ('PLAID', 'TELLER');
|
||||||
|
ALTER TABLE "provider_institution" ALTER COLUMN "provider" TYPE "Provider_new" USING ("provider"::text::"Provider_new");
|
||||||
|
ALTER TYPE "Provider" RENAME TO "Provider_old";
|
||||||
|
ALTER TYPE "Provider_new" RENAME TO "Provider";
|
||||||
|
DROP TYPE "Provider_old";
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "account_account_connection_id_finicity_account_id_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "holding_finicity_position_id_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "investment_transaction_finicity_transaction_id_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "security_finicity_security_id_finicity_security_id_type_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "transaction_finicity_transaction_id_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "user_finicity_customer_id_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "user_finicity_username_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "account" DROP COLUMN "finicity_account_id",
|
||||||
|
DROP COLUMN "finicity_detail",
|
||||||
|
DROP COLUMN "finicity_type";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "account_connection" DROP COLUMN "finicity_error",
|
||||||
|
DROP COLUMN "finicity_institution_id",
|
||||||
|
DROP COLUMN "finicity_institution_login_id";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "holding" DROP COLUMN "finicity_position_id";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "investment_transaction" DROP COLUMN "finicity_investment_transaction_type",
|
||||||
|
DROP COLUMN "finicity_transaction_id";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "security" DROP COLUMN "finicity_asset_class",
|
||||||
|
DROP COLUMN "finicity_fi_asset_class",
|
||||||
|
DROP COLUMN "finicity_security_id",
|
||||||
|
DROP COLUMN "finicity_security_id_type",
|
||||||
|
DROP COLUMN "finicity_type";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "transaction" DROP COLUMN "finicity_categorization",
|
||||||
|
DROP COLUMN "finicity_transaction_id",
|
||||||
|
DROP COLUMN "finicity_type";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user" DROP COLUMN "finicity_customer_id",
|
||||||
|
DROP COLUMN "finicity_username";
|
|
@ -0,0 +1,63 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "transaction"
|
||||||
|
RENAME COLUMN "category" TO "category_old";
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS transactions_enriched;
|
||||||
|
|
||||||
|
ALTER TABLE "transaction"
|
||||||
|
ADD COLUMN "category" TEXT NOT NULL DEFAULT 'Other'::text;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW transactions_enriched AS (
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.created_at as "createdAt",
|
||||||
|
t.updated_at as "updatedAt",
|
||||||
|
t.name,
|
||||||
|
t.account_id as "accountId",
|
||||||
|
t.date,
|
||||||
|
t.flow,
|
||||||
|
COALESCE(
|
||||||
|
t.type_user,
|
||||||
|
CASE
|
||||||
|
-- no matching transaction
|
||||||
|
WHEN t.match_id IS NULL THEN (
|
||||||
|
CASE
|
||||||
|
t.flow
|
||||||
|
WHEN 'INFLOW' THEN (
|
||||||
|
CASE
|
||||||
|
a.classification
|
||||||
|
WHEN 'asset' THEN 'INCOME' :: "TransactionType"
|
||||||
|
WHEN 'liability' THEN 'PAYMENT' :: "TransactionType"
|
||||||
|
END
|
||||||
|
)
|
||||||
|
WHEN 'OUTFLOW' THEN 'EXPENSE' :: "TransactionType"
|
||||||
|
END
|
||||||
|
) -- has matching transaction
|
||||||
|
ELSE (
|
||||||
|
CASE
|
||||||
|
a.classification
|
||||||
|
WHEN 'asset' THEN 'TRANSFER' :: "TransactionType"
|
||||||
|
WHEN 'liability' THEN 'PAYMENT' :: "TransactionType"
|
||||||
|
END
|
||||||
|
)
|
||||||
|
END
|
||||||
|
) AS "type",
|
||||||
|
t.type_user as "typeUser",
|
||||||
|
t.amount,
|
||||||
|
t.currency_code as "currencyCode",
|
||||||
|
t.pending,
|
||||||
|
t.merchant_name as "merchantName",
|
||||||
|
t.category,
|
||||||
|
t.category_user as "categoryUser",
|
||||||
|
t.excluded,
|
||||||
|
t.match_id as "matchId",
|
||||||
|
COALESCE(ac.user_id, a.user_id) as "userId",
|
||||||
|
a.classification as "accountClassification",
|
||||||
|
a.type as "accountType"
|
||||||
|
FROM
|
||||||
|
transaction t
|
||||||
|
inner join account a on a.id = t.account_id
|
||||||
|
left join account_connection ac on a.account_connection_id = ac.id
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "transaction" DROP COLUMN "category_old";
|
|
@ -0,0 +1,127 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "investment_transaction"
|
||||||
|
RENAME COLUMN "category" TO "category_old";
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS holdings_enriched;
|
||||||
|
|
||||||
|
ALTER TABLE "investment_transaction"
|
||||||
|
ADD COLUMN "category" "InvestmentTransactionCategory" NOT NULL DEFAULT 'other'::"InvestmentTransactionCategory";
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW holdings_enriched AS (
|
||||||
|
SELECT
|
||||||
|
h.id,
|
||||||
|
h.account_id,
|
||||||
|
h.security_id,
|
||||||
|
h.quantity,
|
||||||
|
COALESCE(pricing_latest.price_close * h.quantity * COALESCE(s.shares_per_contract, 1), h.value) AS "value",
|
||||||
|
COALESCE(h.cost_basis, tcb.cost_basis * h.quantity) AS "cost_basis",
|
||||||
|
COALESCE(h.cost_basis / h.quantity / COALESCE(s.shares_per_contract, 1), tcb.cost_basis) AS "cost_basis_per_share",
|
||||||
|
pricing_latest.price_close AS "price",
|
||||||
|
pricing_prev.price_close AS "price_prev",
|
||||||
|
h.excluded
|
||||||
|
FROM
|
||||||
|
holding h
|
||||||
|
INNER JOIN security s ON s.id = h.security_id
|
||||||
|
-- latest security pricing
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
price_close
|
||||||
|
FROM
|
||||||
|
security_pricing
|
||||||
|
WHERE
|
||||||
|
security_id = h.security_id
|
||||||
|
ORDER BY
|
||||||
|
date DESC
|
||||||
|
LIMIT 1
|
||||||
|
) pricing_latest ON true
|
||||||
|
-- previous security pricing (for computing daily ∆)
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
price_close
|
||||||
|
FROM
|
||||||
|
security_pricing
|
||||||
|
WHERE
|
||||||
|
security_id = h.security_id
|
||||||
|
ORDER BY
|
||||||
|
date DESC
|
||||||
|
LIMIT 1
|
||||||
|
OFFSET 1
|
||||||
|
) pricing_prev ON true
|
||||||
|
-- calculate cost basis from transactions
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
it.account_id,
|
||||||
|
it.security_id,
|
||||||
|
SUM(it.quantity * it.price) / SUM(it.quantity) AS cost_basis
|
||||||
|
FROM
|
||||||
|
investment_transaction it
|
||||||
|
WHERE
|
||||||
|
it.category = 'buy'
|
||||||
|
AND it.quantity > 0
|
||||||
|
GROUP BY
|
||||||
|
it.account_id,
|
||||||
|
it.security_id
|
||||||
|
) tcb ON tcb.account_id = h.account_id AND tcb.security_id = s.id
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION calculate_return_dietz(p_account_id account.id%type, p_start date, p_end date, out percentage numeric, out amount numeric) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_start date := GREATEST(p_start, (SELECT MIN(date) FROM account_balance WHERE account_id = p_account_id));
|
||||||
|
v_end date := p_end;
|
||||||
|
v_days int := v_end - v_start;
|
||||||
|
BEGIN
|
||||||
|
SELECT
|
||||||
|
ROUND((b1.balance - b0.balance - flows.net) / NULLIF(b0.balance + flows.weighted, 0), 4) AS "percentage",
|
||||||
|
b1.balance - b0.balance - flows.net AS "amount"
|
||||||
|
INTO
|
||||||
|
percentage, amount
|
||||||
|
FROM
|
||||||
|
account a
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(-fw.flow), 0) AS "net",
|
||||||
|
COALESCE(SUM(-fw.flow * fw.weight), 0) AS "weighted"
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
SUM(it.amount) AS flow,
|
||||||
|
(v_days - (it.date - v_start))::numeric / v_days AS weight
|
||||||
|
FROM
|
||||||
|
investment_transaction it
|
||||||
|
WHERE
|
||||||
|
it.account_id = a.id
|
||||||
|
AND it.date BETWEEN v_start AND v_end
|
||||||
|
-- filter for investment_transactions that represent external flows
|
||||||
|
AND it.category = 'transfer'
|
||||||
|
GROUP BY
|
||||||
|
it.date
|
||||||
|
) fw
|
||||||
|
) flows ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
ab.balance AS "balance"
|
||||||
|
FROM
|
||||||
|
account_balance ab
|
||||||
|
WHERE
|
||||||
|
ab.account_id = a.id AND ab.date <= v_start
|
||||||
|
ORDER BY
|
||||||
|
ab.date DESC
|
||||||
|
LIMIT 1
|
||||||
|
) b0 ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
COALESCE(ab.balance, a.current_balance) AS "balance"
|
||||||
|
FROM
|
||||||
|
account_balance ab
|
||||||
|
WHERE
|
||||||
|
ab.account_id = a.id AND ab.date <= v_end
|
||||||
|
ORDER BY
|
||||||
|
ab.date DESC
|
||||||
|
LIMIT 1
|
||||||
|
) b1 ON TRUE
|
||||||
|
WHERE
|
||||||
|
a.id = p_account_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
ALTER TABLE "investment_transaction"
|
||||||
|
DROP COLUMN "category_old";
|
|
@ -42,7 +42,6 @@ enum AccountSyncStatus {
|
||||||
|
|
||||||
enum AccountConnectionType {
|
enum AccountConnectionType {
|
||||||
plaid
|
plaid
|
||||||
finicity
|
|
||||||
teller
|
teller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,11 +64,6 @@ model AccountConnection {
|
||||||
plaidError Json? @map("plaid_error")
|
plaidError Json? @map("plaid_error")
|
||||||
plaidNewAccountsAvailable Boolean @default(false) @map("plaid_new_accounts_available")
|
plaidNewAccountsAvailable Boolean @default(false) @map("plaid_new_accounts_available")
|
||||||
|
|
||||||
// finicity data
|
|
||||||
finicityInstitutionLoginId String? @map("finicity_institution_login_id")
|
|
||||||
finicityInstitutionId String? @map("finicity_institution_id")
|
|
||||||
finicityError Json? @map("finicity_error")
|
|
||||||
|
|
||||||
// teller data
|
// teller data
|
||||||
tellerAccessToken String? @map("teller_access_token")
|
tellerAccessToken String? @map("teller_access_token")
|
||||||
tellerEnrollmentId String? @map("teller_enrollment_id")
|
tellerEnrollmentId String? @map("teller_enrollment_id")
|
||||||
|
@ -108,7 +102,6 @@ enum AccountCategory {
|
||||||
enum AccountProvider {
|
enum AccountProvider {
|
||||||
user
|
user
|
||||||
plaid
|
plaid
|
||||||
finicity
|
|
||||||
teller
|
teller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -155,11 +148,6 @@ model Account {
|
||||||
plaidSubtype String? @map("plaid_subtype")
|
plaidSubtype String? @map("plaid_subtype")
|
||||||
plaidLiability Json? @map("plaid_liability") @db.JsonB
|
plaidLiability Json? @map("plaid_liability") @db.JsonB
|
||||||
|
|
||||||
// finicity data
|
|
||||||
finicityAccountId String? @map("finicity_account_id")
|
|
||||||
finicityType String? @map("finicity_type")
|
|
||||||
finicityDetail Json? @map("finicity_detail") @db.JsonB
|
|
||||||
|
|
||||||
// teller data
|
// teller data
|
||||||
tellerAccountId String? @map("teller_account_id")
|
tellerAccountId String? @map("teller_account_id")
|
||||||
tellerType String? @map("teller_type")
|
tellerType String? @map("teller_type")
|
||||||
|
@ -184,7 +172,6 @@ model Account {
|
||||||
investmentTransactions InvestmentTransaction[]
|
investmentTransactions InvestmentTransaction[]
|
||||||
|
|
||||||
@@unique([accountConnectionId, plaidAccountId])
|
@@unique([accountConnectionId, plaidAccountId])
|
||||||
@@unique([accountConnectionId, finicityAccountId])
|
|
||||||
@@unique([accountConnectionId, tellerAccountId])
|
@@unique([accountConnectionId, tellerAccountId])
|
||||||
@@index([accountConnectionId])
|
@@index([accountConnectionId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@ -215,9 +202,6 @@ model Holding {
|
||||||
// plaid data
|
// plaid data
|
||||||
plaidHoldingId String? @unique @map("plaid_holding_id") // this is an artificial ID `account[<account_id>].security[<security_id>]`
|
plaidHoldingId String? @unique @map("plaid_holding_id") // this is an artificial ID `account[<account_id>].security[<security_id>]`
|
||||||
|
|
||||||
// finicity data
|
|
||||||
finicityPositionId String? @unique @map("finicity_position_id")
|
|
||||||
|
|
||||||
@@map("holding")
|
@@map("holding")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -233,34 +217,28 @@ enum InvestmentTransactionCategory {
|
||||||
}
|
}
|
||||||
|
|
||||||
model InvestmentTransaction {
|
model InvestmentTransaction {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||||
accountId Int @map("account_id")
|
accountId Int @map("account_id")
|
||||||
security Security? @relation(fields: [securityId], references: [id], onDelete: Cascade)
|
security Security? @relation(fields: [securityId], references: [id], onDelete: Cascade)
|
||||||
securityId Int? @map("security_id")
|
securityId Int? @map("security_id")
|
||||||
date DateTime @db.Date
|
date DateTime @db.Date
|
||||||
name String
|
name String
|
||||||
amount Decimal @db.Decimal(19, 4)
|
amount Decimal @db.Decimal(19, 4)
|
||||||
fees Decimal? @db.Decimal(19, 4)
|
fees Decimal? @db.Decimal(19, 4)
|
||||||
flow TransactionFlow @default(dbgenerated("\nCASE\n WHEN (amount < (0)::numeric) THEN 'INFLOW'::\"TransactionFlow\"\n ELSE 'OUTFLOW'::\"TransactionFlow\"\nEND"))
|
flow TransactionFlow @default(dbgenerated("\nCASE\n WHEN (amount < (0)::numeric) THEN 'INFLOW'::\"TransactionFlow\"\n ELSE 'OUTFLOW'::\"TransactionFlow\"\nEND"))
|
||||||
quantity Decimal @db.Decimal(36, 18)
|
quantity Decimal @db.Decimal(36, 18)
|
||||||
price Decimal @db.Decimal(23, 8)
|
price Decimal @db.Decimal(23, 8)
|
||||||
currencyCode String @default("USD") @map("currency_code")
|
currencyCode String @default("USD") @map("currency_code")
|
||||||
|
category InvestmentTransactionCategory @default(other)
|
||||||
// Derived from provider types
|
|
||||||
category InvestmentTransactionCategory @default(dbgenerated("\nCASE\n WHEN (plaid_type = 'buy'::text) THEN 'buy'::\"InvestmentTransactionCategory\"\n WHEN (plaid_type = 'sell'::text) THEN 'sell'::\"InvestmentTransactionCategory\"\n WHEN (plaid_subtype = ANY (ARRAY['dividend'::text, 'qualified dividend'::text, 'non-qualified dividend'::text])) THEN 'dividend'::\"InvestmentTransactionCategory\"\n WHEN (plaid_subtype = ANY (ARRAY['non-resident tax'::text, 'tax'::text, 'tax withheld'::text])) THEN 'tax'::\"InvestmentTransactionCategory\"\n WHEN ((plaid_type = 'fee'::text) OR (plaid_subtype = ANY (ARRAY['account fee'::text, 'legal fee'::text, 'management fee'::text, 'margin expense'::text, 'transfer fee'::text, 'trust fee'::text]))) THEN 'fee'::\"InvestmentTransactionCategory\"\n WHEN (plaid_type = 'cash'::text) THEN 'transfer'::\"InvestmentTransactionCategory\"\n WHEN (plaid_type = 'cancel'::text) THEN 'cancel'::\"InvestmentTransactionCategory\"\n WHEN (finicity_investment_transaction_type = ANY (ARRAY['purchased'::text, 'purchaseToClose'::text, 'purchaseToCover'::text, 'dividendReinvest'::text, 'reinvestOfIncome'::text])) THEN 'buy'::\"InvestmentTransactionCategory\"\n WHEN (finicity_investment_transaction_type = ANY (ARRAY['sold'::text, 'soldToClose'::text, 'soldToOpen'::text])) THEN 'sell'::\"InvestmentTransactionCategory\"\n WHEN (finicity_investment_transaction_type = 'dividend'::text) THEN 'dividend'::\"InvestmentTransactionCategory\"\n WHEN (finicity_investment_transaction_type = 'tax'::text) THEN 'tax'::\"InvestmentTransactionCategory\"\n WHEN (finicity_investment_transaction_type = 'fee'::text) THEN 'fee'::\"InvestmentTransactionCategory\"\n WHEN (finicity_investment_transaction_type = ANY (ARRAY['transfer'::text, 'contribution'::text, 'deposit'::text, 'income'::text, 'interest'::text])) THEN 'transfer'::\"InvestmentTransactionCategory\"\n WHEN (finicity_investment_transaction_type = 'cancel'::text) THEN 'cancel'::\"InvestmentTransactionCategory\"\n ELSE 'other'::\"InvestmentTransactionCategory\"\nEND"))
|
|
||||||
|
|
||||||
// plaid data
|
// plaid data
|
||||||
plaidInvestmentTransactionId String? @unique @map("plaid_investment_transaction_id")
|
plaidInvestmentTransactionId String? @unique @map("plaid_investment_transaction_id")
|
||||||
plaidType String? @map("plaid_type")
|
plaidType String? @map("plaid_type")
|
||||||
plaidSubtype String? @map("plaid_subtype")
|
plaidSubtype String? @map("plaid_subtype")
|
||||||
|
|
||||||
// finicity data
|
|
||||||
finicityTransactionId String? @unique @map("finicity_transaction_id")
|
|
||||||
finicityInvestmentTransactionType String? @map("finicity_investment_transaction_type")
|
|
||||||
|
|
||||||
@@index([accountId, date])
|
@@index([accountId, date])
|
||||||
@@map("investment_transaction")
|
@@map("investment_transaction")
|
||||||
}
|
}
|
||||||
|
@ -283,18 +261,10 @@ model Security {
|
||||||
plaidType String? @map("plaid_type")
|
plaidType String? @map("plaid_type")
|
||||||
plaidIsCashEquivalent Boolean? @map("plaid_is_cash_equivalent")
|
plaidIsCashEquivalent Boolean? @map("plaid_is_cash_equivalent")
|
||||||
|
|
||||||
// finicity data
|
|
||||||
finicitySecurityId String? @map("finicity_security_id")
|
|
||||||
finicitySecurityIdType String? @map("finicity_security_id_type")
|
|
||||||
finicityType String? @map("finicity_type")
|
|
||||||
finicityAssetClass String? @map("finicity_asset_class")
|
|
||||||
finicityFIAssetClass String? @map("finicity_fi_asset_class")
|
|
||||||
|
|
||||||
holdings Holding[]
|
holdings Holding[]
|
||||||
investmentTransactions InvestmentTransaction[]
|
investmentTransactions InvestmentTransaction[]
|
||||||
pricing SecurityPricing[]
|
pricing SecurityPricing[]
|
||||||
|
|
||||||
@@unique([finicitySecurityId, finicitySecurityIdType])
|
|
||||||
@@map("security")
|
@@map("security")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -340,7 +310,7 @@ model Transaction {
|
||||||
currencyCode String @default("USD") @map("currency_code")
|
currencyCode String @default("USD") @map("currency_code")
|
||||||
pending Boolean @default(false)
|
pending Boolean @default(false)
|
||||||
merchantName String? @map("merchant_name")
|
merchantName String? @map("merchant_name")
|
||||||
category String @default(dbgenerated("COALESCE(category_user,\nCASE\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'INCOME'::text) THEN 'Income'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = ANY (ARRAY['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text])) THEN 'Housing Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = 'LOAN_PAYMENTS_CAR_PAYMENT'::text) THEN 'Vehicle Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'LOAN_PAYMENTS'::text) THEN 'Other Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'HOME_IMPROVEMENT'::text) THEN 'Home Improvement'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'GENERAL_MERCHANDISE'::text) THEN 'Shopping'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = 'RENT_AND_UTILITIES'::text) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'RENT_AND_UTILITIES_RENT'::text)) THEN 'Utilities'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'FOOD_AND_DRINK'::text) THEN 'Food and Drink'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRANSPORTATION'::text) THEN 'Transportation'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRAVEL'::text) THEN 'Travel'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = ANY (ARRAY['PERSONAL_CARE'::text, 'MEDICAL'::text])) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'MEDICAL_VETERINARY_SERVICES'::text)) THEN 'Health'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Income'::text, 'Paycheck'::text])) THEN 'Income'::text\n WHEN ((finicity_categorization ->> 'category'::text) = 'Mortgage & Rent'::text) THEN 'Housing Payments'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Furnishings'::text, 'Home Services'::text, 'Home Improvement'::text, 'Lawn and Garden'::text])) THEN 'Home Improvement'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Streaming Services'::text, 'Home Phone'::text, 'Television'::text, 'Bills & Utilities'::text, 'Utilities'::text, 'Internet / Broadband Charges'::text, 'Mobile Phone'::text])) THEN 'Utilities'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Fast Food'::text, 'Food & Dining'::text, 'Restaurants'::text, 'Coffee Shops'::text, 'Alcohol & Bars'::text, 'Groceries'::text])) THEN 'Food and Drink'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Auto & Transport'::text, 'Gas & Fuel'::text, 'Auto Insurance'::text])) THEN 'Transportation'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Hotel'::text, 'Travel'::text, 'Rental Car & Taxi'::text])) THEN 'Travel'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Health Insurance'::text, 'Doctor'::text, 'Pharmacy'::text, 'Eyecare'::text, 'Health & Fitness'::text, 'Personal Care'::text])) THEN 'Health'::text\n WHEN (teller_category = 'income'::text) THEN 'Income'::text\n WHEN (teller_category = 'home'::text) THEN 'Home Improvement'::text\n WHEN (teller_category = ANY (ARRAY['phone'::text, 'utilities'::text])) THEN 'Utilities'::text\n WHEN (teller_category = ANY (ARRAY['dining'::text, 'bar'::text, 'groceries'::text])) THEN 'Food and Drink'::text\n WHEN (teller_category = ANY (ARRAY['clothing'::text, 'entertainment'::text, 'shopping'::text, 'electronics'::text, 'software'::text, 'sport'::text])) THEN 'Shopping'::text\n WHEN (teller_category = ANY (ARRAY['transportation'::text, 'fuel'::text])) THEN 'Transportation'::text\n WHEN (teller_category = ANY (ARRAY['accommodation'::text, 'transport'::text])) THEN 'Travel'::text\n WHEN (teller_category = 'health'::text) THEN 'Health'::text\n WHEN (teller_category = ANY (ARRAY['loan'::text, 'tax'::text, 'insurance'::text, 'office'::text])) THEN 'Other Payments'::text\n ELSE 'Other'::text\nEND)"))
|
category String @default("Other")
|
||||||
categoryUser String? @map("category_user")
|
categoryUser String? @map("category_user")
|
||||||
excluded Boolean @default(false)
|
excluded Boolean @default(false)
|
||||||
|
|
||||||
|
@ -355,11 +325,6 @@ model Transaction {
|
||||||
plaidCategoryId String? @map("plaid_category_id")
|
plaidCategoryId String? @map("plaid_category_id")
|
||||||
plaidPersonalFinanceCategory Json? @map("plaid_personal_finance_category")
|
plaidPersonalFinanceCategory Json? @map("plaid_personal_finance_category")
|
||||||
|
|
||||||
// finicity data
|
|
||||||
finicityTransactionId String? @unique @map("finicity_transaction_id")
|
|
||||||
finicityType String? @map("finicity_type")
|
|
||||||
finicityCategorization Json? @map("finicity_categorization") @db.JsonB
|
|
||||||
|
|
||||||
// teller data
|
// teller data
|
||||||
tellerTransactionId String? @unique @map("teller_transaction_id")
|
tellerTransactionId String? @unique @map("teller_transaction_id")
|
||||||
tellerType String? @map("teller_type")
|
tellerType String? @map("teller_type")
|
||||||
|
@ -442,10 +407,6 @@ model User {
|
||||||
stripeCurrentPeriodEnd DateTime? @map("stripe_current_period_end") @db.Timestamptz(6)
|
stripeCurrentPeriodEnd DateTime? @map("stripe_current_period_end") @db.Timestamptz(6)
|
||||||
stripeCancelAt DateTime? @map("stripe_cancel_at") @db.Timestamptz(6)
|
stripeCancelAt DateTime? @map("stripe_cancel_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
// finicity data
|
|
||||||
finicityUsername String? @unique @map("finicity_username")
|
|
||||||
finicityCustomerId String? @unique @map("finicity_customer_id")
|
|
||||||
|
|
||||||
// plaid data
|
// plaid data
|
||||||
plaidLinkToken String? @map("plaid_link_token") // temporary token stored to maintain state across browsers
|
plaidLinkToken String? @map("plaid_link_token") // temporary token stored to maintain state across browsers
|
||||||
|
|
||||||
|
@ -492,7 +453,6 @@ model Institution {
|
||||||
|
|
||||||
enum Provider {
|
enum Provider {
|
||||||
PLAID
|
PLAID
|
||||||
FINICITY
|
|
||||||
TELLER
|
TELLER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,22 +8,29 @@ const prisma = new PrismaClient()
|
||||||
*/
|
*/
|
||||||
async function main() {
|
async function main() {
|
||||||
const institutions: (Pick<Institution, 'id' | 'name'> & {
|
const institutions: (Pick<Institution, 'id' | 'name'> & {
|
||||||
providers: { provider: Provider; providerId: string; rank?: number }[]
|
providers: { provider: Provider; providerId: string; logoUrl: string; rank?: number }[]
|
||||||
})[] = [
|
})[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Capital One',
|
name: 'Capital One',
|
||||||
providers: [
|
providers: [
|
||||||
{ provider: 'PLAID', providerId: 'ins_9', rank: 1 },
|
{
|
||||||
{ provider: 'FINICITY', providerId: '170778' },
|
provider: Provider.TELLER,
|
||||||
|
providerId: 'capital_one',
|
||||||
|
logoUrl: 'https://teller.io/images/banks/capital_one.jpg',
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: 'Discover Bank',
|
name: 'Wells Fargo',
|
||||||
providers: [
|
providers: [
|
||||||
{ provider: 'PLAID', providerId: 'ins_33' },
|
{
|
||||||
{ provider: 'FINICITY', providerId: '13796', rank: 1 },
|
provider: Provider.TELLER,
|
||||||
|
providerId: 'wells_fargo',
|
||||||
|
logoUrl: 'https://teller.io/images/banks/wells_fargo.jpg',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { faker } from '@faker-js/faker'
|
import { faker } from '@faker-js/faker'
|
||||||
import type { TellerTypes } from '../../libs/teller-api/src'
|
import type { TellerTypes } from '../../libs/teller-api/src'
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
|
import { DateTime } from 'luxon'
|
||||||
|
|
||||||
function generateSubType(
|
function generateSubType(
|
||||||
type: TellerTypes.AccountTypes
|
type: TellerTypes.AccountTypes
|
||||||
|
@ -23,6 +25,8 @@ type GenerateAccountsParams = {
|
||||||
enrollmentId: string
|
enrollmentId: string
|
||||||
institutionName: string
|
institutionName: string
|
||||||
institutionId: string
|
institutionId: string
|
||||||
|
accountType?: TellerTypes.AccountTypes
|
||||||
|
accountSubType?: TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateAccounts({
|
export function generateAccounts({
|
||||||
|
@ -30,12 +34,15 @@ export function generateAccounts({
|
||||||
enrollmentId,
|
enrollmentId,
|
||||||
institutionName,
|
institutionName,
|
||||||
institutionId,
|
institutionId,
|
||||||
|
accountType,
|
||||||
|
accountSubType,
|
||||||
}: GenerateAccountsParams) {
|
}: GenerateAccountsParams) {
|
||||||
const accounts: TellerTypes.Account[] = []
|
const accounts: TellerTypes.Account[] = []
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const accountId = faker.string.uuid()
|
const accountId = faker.string.uuid()
|
||||||
const lastFour = faker.finance.creditCardNumber().slice(-4)
|
const lastFour = faker.finance.creditCardNumber().slice(-4)
|
||||||
const type: TellerTypes.AccountTypes = faker.helpers.arrayElement(['depository', 'credit'])
|
const type: TellerTypes.AccountTypes =
|
||||||
|
accountType ?? faker.helpers.arrayElement(['depository', 'credit'])
|
||||||
let subType: TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype
|
let subType: TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype
|
||||||
subType = generateSubType(type)
|
subType = generateSubType(type)
|
||||||
|
|
||||||
|
@ -99,6 +106,8 @@ type GenerateAccountsWithBalancesParams = {
|
||||||
enrollmentId: string
|
enrollmentId: string
|
||||||
institutionName: string
|
institutionName: string
|
||||||
institutionId: string
|
institutionId: string
|
||||||
|
accountType?: TellerTypes.AccountTypes
|
||||||
|
accountSubType?: TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateAccountsWithBalances({
|
export function generateAccountsWithBalances({
|
||||||
|
@ -106,7 +115,9 @@ export function generateAccountsWithBalances({
|
||||||
enrollmentId,
|
enrollmentId,
|
||||||
institutionName,
|
institutionName,
|
||||||
institutionId,
|
institutionId,
|
||||||
}: GenerateAccountsWithBalancesParams): TellerTypes.AccountWithBalances[] {
|
accountType,
|
||||||
|
accountSubType,
|
||||||
|
}: GenerateAccountsWithBalancesParams): TellerTypes.GetAccountsResponse {
|
||||||
const accountsWithBalances: TellerTypes.AccountWithBalances[] = []
|
const accountsWithBalances: TellerTypes.AccountWithBalances[] = []
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const account = generateAccounts({
|
const account = generateAccounts({
|
||||||
|
@ -114,6 +125,8 @@ export function generateAccountsWithBalances({
|
||||||
enrollmentId,
|
enrollmentId,
|
||||||
institutionName,
|
institutionName,
|
||||||
institutionId,
|
institutionId,
|
||||||
|
accountType,
|
||||||
|
accountSubType,
|
||||||
})[0]
|
})[0]
|
||||||
const balance = generateBalance(account.id)
|
const balance = generateBalance(account.id)
|
||||||
accountsWithBalances.push({
|
accountsWithBalances.push({
|
||||||
|
@ -170,7 +183,10 @@ export function generateTransactions(count: number, accountId: string): TellerTy
|
||||||
running_balance: null,
|
running_balance: null,
|
||||||
description: faker.word.words({ count: { min: 3, max: 10 } }),
|
description: faker.word.words({ count: { min: 3, max: 10 } }),
|
||||||
id: transactionId,
|
id: transactionId,
|
||||||
date: faker.date.recent({ days: 30 }).toISOString().split('T')[0], // recent date in 'YYYY-MM-DD' format
|
date: faker.date
|
||||||
|
.between({ from: lowerBound.toJSDate(), to: now.toJSDate() })
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0], // recent date in 'YYYY-MM-DD' format
|
||||||
account_id: accountId,
|
account_id: accountId,
|
||||||
links: {
|
links: {
|
||||||
account: `https://api.teller.io/accounts/${accountId}`,
|
account: `https://api.teller.io/accounts/${accountId}`,
|
||||||
|
@ -246,3 +262,35 @@ export function generateConnection(): GenerateConnectionsResponse {
|
||||||
transactions,
|
transactions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const now = DateTime.fromISO('2022-01-03', { zone: 'utc' })
|
||||||
|
|
||||||
|
export const lowerBound = DateTime.fromISO('2021-12-01', { zone: 'utc' })
|
||||||
|
|
||||||
|
export const testDates = {
|
||||||
|
now,
|
||||||
|
lowerBound,
|
||||||
|
totalDays: now.diff(lowerBound, 'days').days,
|
||||||
|
prismaWhereFilter: {
|
||||||
|
date: {
|
||||||
|
gte: lowerBound.toJSDate(),
|
||||||
|
lte: now.toJSDate(),
|
||||||
|
},
|
||||||
|
} as Prisma.AccountBalanceWhereInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateDailyBalances(startingBalance, transactions, dateInterval) {
|
||||||
|
transactions.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||||
|
|
||||||
|
const balanceChanges = {}
|
||||||
|
|
||||||
|
transactions.forEach((transaction) => {
|
||||||
|
const date = new Date(transaction.date).toISOString().split('T')[0]
|
||||||
|
balanceChanges[date] = (balanceChanges[date] || 0) + Number(transaction.amount)
|
||||||
|
})
|
||||||
|
return dateInterval.map((date) => {
|
||||||
|
return Object.keys(balanceChanges)
|
||||||
|
.filter((d) => d <= date)
|
||||||
|
.reduce((acc, d) => acc + balanceChanges[d], startingBalance)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1 +0,0 @@
|
||||||
export * from './finicityTestData'
|
|
|
@ -1,3 +1,2 @@
|
||||||
export * as FinicityTestData from './finicity'
|
|
||||||
export * as PlaidTestData from './plaid'
|
export * as PlaidTestData from './plaid'
|
||||||
export * as PolygonTestData from './polygon'
|
export * as PolygonTestData from './polygon'
|
||||||
|
|
|
@ -19,7 +19,6 @@
|
||||||
"@maybe-finance/client/features": ["libs/client/features/src/index.ts"],
|
"@maybe-finance/client/features": ["libs/client/features/src/index.ts"],
|
||||||
"@maybe-finance/client/shared": ["libs/client/shared/src/index.ts"],
|
"@maybe-finance/client/shared": ["libs/client/shared/src/index.ts"],
|
||||||
"@maybe-finance/design-system": ["libs/design-system/src/index.ts"],
|
"@maybe-finance/design-system": ["libs/design-system/src/index.ts"],
|
||||||
"@maybe-finance/finicity-api": ["libs/finicity-api/src/index.ts"],
|
|
||||||
"@maybe-finance/server/features": ["libs/server/features/src/index.ts"],
|
"@maybe-finance/server/features": ["libs/server/features/src/index.ts"],
|
||||||
"@maybe-finance/server/shared": ["libs/server/shared/src/index.ts"],
|
"@maybe-finance/server/shared": ["libs/server/shared/src/index.ts"],
|
||||||
"@maybe-finance/shared": ["libs/shared/src/index.ts"],
|
"@maybe-finance/shared": ["libs/shared/src/index.ts"],
|
||||||
|
|
|
@ -257,30 +257,6 @@
|
||||||
"tags": ["scope:app"],
|
"tags": ["scope:app"],
|
||||||
"implicitDependencies": ["client", "server", "workers"]
|
"implicitDependencies": ["client", "server", "workers"]
|
||||||
},
|
},
|
||||||
"finicity-api": {
|
|
||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
||||||
"root": "libs/finicity-api",
|
|
||||||
"sourceRoot": "libs/finicity-api/src",
|
|
||||||
"projectType": "library",
|
|
||||||
"targets": {
|
|
||||||
"lint": {
|
|
||||||
"executor": "@nrwl/linter:eslint",
|
|
||||||
"outputs": ["{options.outputFile}"],
|
|
||||||
"options": {
|
|
||||||
"lintFilePatterns": ["libs/finicity-api/**/*.ts"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
"executor": "@nrwl/jest:jest",
|
|
||||||
"outputs": ["coverage/libs/finicity-api"],
|
|
||||||
"options": {
|
|
||||||
"jestConfig": "libs/finicity-api/jest.config.ts",
|
|
||||||
"passWithNoTests": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tags": ["scope:shared"]
|
|
||||||
},
|
|
||||||
"server": {
|
"server": {
|
||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "apps/server",
|
"root": "apps/server",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue