mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-08 15:05:22 +02:00
Merge pull request #157 from MichaelDeBoey/remove-Finicity-usage
feat: remove Finicity usage
This commit is contained in:
commit
9ebaa29116
74 changed files with 297 additions and 22969 deletions
|
@ -58,5 +58,3 @@ NX_POSTMARK_API_TOKEN=
|
|||
# for now, they are still required.
|
||||
########################################################################
|
||||
NX_PLAID_SECRET=
|
||||
NX_FINICITY_APP_KEY=
|
||||
NX_FINICITY_PARTNER_SECRET=
|
||||
|
|
12
README.md
12
README.md
|
@ -6,11 +6,11 @@
|
|||
|
||||
## 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.
|
||||
|
||||
|
@ -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:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
Then run the following yarn commands:
|
||||
|
||||
```
|
||||
```shell
|
||||
yarn install
|
||||
yarn run dev:services:all
|
||||
yarn prisma:migrate:dev
|
||||
|
@ -95,7 +95,7 @@ To contribute, please see our [contribution guide](https://github.com/maybe-fina
|
|||
|
||||
## 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.
|
||||
|
||||
|
|
|
@ -35,7 +35,6 @@ import {
|
|||
accountRollupRouter,
|
||||
valuationsRouter,
|
||||
institutionsRouter,
|
||||
finicityRouter,
|
||||
tellerRouter,
|
||||
transactionsRouter,
|
||||
holdingsRouter,
|
||||
|
@ -110,7 +109,6 @@ app.use(
|
|||
app.use('/v1/stripe', express.raw({ type: 'application/json' }))
|
||||
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
app.use(express.json({ limit: '50mb' })) // Finicity sends large response bodies for webhooks
|
||||
|
||||
// =========================================
|
||||
// API ⬇️
|
||||
|
@ -158,7 +156,6 @@ app.use('/v1', validateAuthJwt)
|
|||
app.use('/v1/users', usersRouter)
|
||||
app.use('/v1/e2e', e2eRouter)
|
||||
app.use('/v1/plaid', plaidRouter)
|
||||
app.use('/v1/finicity', finicityRouter)
|
||||
app.use('/v1/teller', tellerRouter)
|
||||
app.use('/v1/accounts', accountsRouter)
|
||||
app.use('/v1/account-rollup', accountRollupRouter)
|
||||
|
|
|
@ -37,10 +37,7 @@ import {
|
|||
TransactionBalanceSyncStrategy,
|
||||
InvestmentTransactionBalanceSyncStrategy,
|
||||
PlaidETL,
|
||||
FinicityService,
|
||||
FinicityETL,
|
||||
InstitutionProviderFactory,
|
||||
FinicityWebhookHandler,
|
||||
PlaidWebhookHandler,
|
||||
TellerService,
|
||||
TellerETL,
|
||||
|
@ -56,7 +53,6 @@ import {
|
|||
} from '@maybe-finance/server/features'
|
||||
import prisma from './prisma'
|
||||
import plaid, { getPlaidWebhookUrl } from './plaid'
|
||||
import finicity, { getFinicityTxPushUrl, getFinicityWebhookUrl } from './finicity'
|
||||
import teller, { getTellerWebhookUrl } from './teller'
|
||||
import stripe from './stripe'
|
||||
import postmark from './postmark'
|
||||
|
@ -136,15 +132,6 @@ const plaidService = new PlaidService(
|
|||
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(
|
||||
logger.child({ service: 'TellerService' }),
|
||||
prisma,
|
||||
|
@ -159,7 +146,6 @@ const tellerService = new TellerService(
|
|||
|
||||
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
|
||||
plaid: plaidService,
|
||||
finicity: finicityService,
|
||||
teller: tellerService,
|
||||
})
|
||||
|
||||
|
@ -239,7 +225,6 @@ const userService = new UserService(
|
|||
|
||||
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||
PLAID: plaidService,
|
||||
FINICITY: finicityService,
|
||||
TELLER: tellerService,
|
||||
})
|
||||
|
||||
|
@ -279,14 +264,6 @@ const plaidWebhooks = new PlaidWebhookHandler(
|
|||
queueService
|
||||
)
|
||||
|
||||
const finicityWebhooks = new FinicityWebhookHandler(
|
||||
logger.child({ service: 'FinicityWebhookHandler' }),
|
||||
prisma,
|
||||
finicity,
|
||||
accountConnectionService,
|
||||
getFinicityTxPushUrl()
|
||||
)
|
||||
|
||||
const stripeWebhooks = new StripeWebhookHandler(
|
||||
logger.child({ service: 'StripeWebhookHandler' }),
|
||||
prisma,
|
||||
|
@ -358,8 +335,6 @@ export async function createContext(req: Request) {
|
|||
queueService,
|
||||
plaidService,
|
||||
plaidWebhooks,
|
||||
finicityService,
|
||||
finicityWebhooks,
|
||||
stripeWebhooks,
|
||||
tellerService,
|
||||
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 './validate-auth-jwt'
|
||||
export * from './validate-plaid-jwt'
|
||||
export * from './validate-finicity-signature'
|
||||
export * from './validate-teller-signature'
|
||||
export { default as maintenance } from './maintenance'
|
||||
export * from './identify-user'
|
||||
|
|
|
@ -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(
|
||||
'/:id/sync',
|
||||
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 webhooksRouter } from './webhooks.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 valuationsRouter } from './valuations.router'
|
||||
export { default as institutionsRouter } from './institutions.router'
|
||||
|
|
|
@ -27,11 +27,10 @@ router.post(
|
|||
resolve: async ({ ctx }) => {
|
||||
ctx.ability.throwUnlessCan('update', 'Institution')
|
||||
|
||||
// Sync all Plaid + Finicity institutions
|
||||
await ctx.queueService.getQueue('sync-institution').addBulk([
|
||||
{ name: 'sync-plaid-institutions', data: {} },
|
||||
{ name: 'sync-finicity-institutions', data: {} },
|
||||
])
|
||||
// Sync all Plaid institutions
|
||||
await ctx.queueService
|
||||
.getQueue('sync-institution')
|
||||
.addBulk([{ name: 'sync-plaid-institutions', data: {} }])
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import type { FinicityTypes } from '@maybe-finance/finicity-api'
|
||||
import { validatePlaidJwt, validateFinicitySignature, validateTellerSignature } from '../middleware'
|
||||
import { validatePlaidJwt, validateTellerSignature } from '../middleware'
|
||||
import endpoint from '../lib/endpoint'
|
||||
import stripe from '../lib/stripe'
|
||||
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(
|
||||
'/stripe/webhook',
|
||||
endpoint.create({
|
||||
|
|
|
@ -36,11 +36,6 @@ const envSchema = z.object({
|
|||
NX_PLAID_SECRET: z.string(),
|
||||
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_APP_ID: z.string().default('REPLACE_THIS'),
|
||||
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: {
|
||||
authId,
|
||||
email: faker.internet.email(),
|
||||
finicityCustomerId: faker.string.uuid(),
|
||||
tellerUserId: faker.string.uuid(),
|
||||
},
|
||||
}),
|
||||
|
|
|
@ -20,8 +20,6 @@ import {
|
|||
AccountQueryService,
|
||||
AccountService,
|
||||
BalanceSyncStrategyFactory,
|
||||
FinicityETL,
|
||||
FinicityService,
|
||||
InstitutionProviderFactory,
|
||||
InstitutionService,
|
||||
InvestmentTransactionBalanceSyncStrategy,
|
||||
|
@ -56,7 +54,6 @@ import Redis from 'ioredis'
|
|||
import logger from './logger'
|
||||
import prisma from './prisma'
|
||||
import plaid from './plaid'
|
||||
import finicity from './finicity'
|
||||
import teller from './teller'
|
||||
import postmark from './postmark'
|
||||
import stripe from './stripe'
|
||||
|
@ -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(
|
||||
logger.child({ service: 'TellerService' }),
|
||||
prisma,
|
||||
|
@ -141,7 +129,6 @@ const tellerService = new TellerService(
|
|||
|
||||
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
|
||||
plaid: plaidService,
|
||||
finicity: finicityService,
|
||||
teller: tellerService,
|
||||
})
|
||||
|
||||
|
@ -258,7 +245,6 @@ export const securityPricingProcessor: ISecurityPricingProcessor = new SecurityP
|
|||
|
||||
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||
PLAID: plaidService,
|
||||
FINICITY: finicityService,
|
||||
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_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_APP_ID: z.string().default('REPLACE_THIS'),
|
||||
NX_TELLER_ENV: z.string().default('sandbox'),
|
||||
|
|
|
@ -112,11 +112,6 @@ syncInstitutionQueue.process(
|
|||
async () => await institutionService.sync('PLAID')
|
||||
)
|
||||
|
||||
syncInstitutionQueue.process(
|
||||
'sync-finicity-institutions',
|
||||
async () => await institutionService.sync('FINICITY')
|
||||
)
|
||||
|
||||
syncInstitutionQueue.process(
|
||||
'sync-teller-institutions',
|
||||
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(
|
||||
'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,
|
||||
useDebounce,
|
||||
usePlaid,
|
||||
useFinicity,
|
||||
useTellerConfig,
|
||||
useTellerConnect,
|
||||
} from '@maybe-finance/client/shared'
|
||||
|
@ -39,7 +38,6 @@ export default function AccountTypeSelector({
|
|||
const config = useTellerConfig(logger)
|
||||
|
||||
const { openPlaid } = usePlaid()
|
||||
const { openFinicity } = useFinicity()
|
||||
const { open: openTeller } = useTellerConnect(config, logger)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
@ -80,9 +78,6 @@ export default function AccountTypeSelector({
|
|||
case 'PLAID':
|
||||
openPlaid(providerInstitution.providerId)
|
||||
break
|
||||
case 'FINICITY':
|
||||
openFinicity(providerInstitution.providerId)
|
||||
break
|
||||
case 'TELLER':
|
||||
openTeller(providerInstitution.providerId)
|
||||
break
|
||||
|
@ -158,9 +153,6 @@ export default function AccountTypeSelector({
|
|||
case 'PLAID':
|
||||
openPlaid(data.providerId)
|
||||
break
|
||||
case 'FINICITY':
|
||||
openFinicity(data.providerId)
|
||||
break
|
||||
case 'TELLER':
|
||||
openTeller(data.providerId)
|
||||
break
|
||||
|
|
|
@ -72,18 +72,10 @@ const brokerages: GridImage[] = [
|
|||
{
|
||||
src: 'fidelity.png',
|
||||
alt: 'Fidelity',
|
||||
institution: {
|
||||
provider: 'FINICITY',
|
||||
providerId: '9913',
|
||||
},
|
||||
},
|
||||
{
|
||||
src: 'vanguard.png',
|
||||
alt: 'Vanguard',
|
||||
institution: {
|
||||
provider: 'FINICITY',
|
||||
providerId: '3078',
|
||||
},
|
||||
},
|
||||
{
|
||||
src: 'wealthfront.png',
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
export * from './useAccountApi'
|
||||
export * from './useAccountConnectionApi'
|
||||
export * from './useAuthUserApi'
|
||||
export * from './useFinicityApi'
|
||||
export * from './useInstitutionApi'
|
||||
export * from './useUserApi'
|
||||
export * from './usePlaidApi'
|
||||
|
|
|
@ -33,13 +33,6 @@ const AccountConnectionApi = (axios: AxiosInstance) => ({
|
|||
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']) {
|
||||
const { data } = await axios.post<SharedType.AccountConnection>(
|
||||
`/connections/${id}/disconnect`
|
||||
|
@ -119,10 +112,6 @@ export function useAccountConnectionApi() {
|
|||
const useCreatePlaidLinkToken = (mode: SharedType.PlaidLinkUpdateMode) =>
|
||||
useMutation((id: SharedType.AccountConnection['id']) => api.createPlaidLinkToken(id, mode))
|
||||
|
||||
const useCreateFinicityFixConnectUrl = (
|
||||
options?: UseMutationOptions<{ link: string }, unknown, number, unknown>
|
||||
) => useMutation(api.createFinicityFixConnectUrl, options)
|
||||
|
||||
const useDisconnectConnection = () =>
|
||||
useMutation(api.disconnect, {
|
||||
onSuccess: (data) => {
|
||||
|
@ -188,7 +177,6 @@ export function useAccountConnectionApi() {
|
|||
useDeleteConnection,
|
||||
useDeleteAllConnections,
|
||||
useCreatePlaidLinkToken,
|
||||
useCreateFinicityFixConnectUrl,
|
||||
useReconnectConnection,
|
||||
useDisconnectConnection,
|
||||
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 './useDebounce'
|
||||
export * from './useFinicity'
|
||||
export * from './useInterval'
|
||||
export * from './useLastUpdated'
|
||||
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 =
|
||||
| { view: 'idle' }
|
||||
| { view: 'add-plaid'; linkToken: string }
|
||||
| { view: 'add-finicity' }
|
||||
| { view: 'add-teller' }
|
||||
| { view: 'add-account' }
|
||||
| { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> }
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -71,7 +71,6 @@ export class InvestmentTransactionBalanceSyncStrategy extends BalanceSyncStrateg
|
|||
AND it.date BETWEEN ${pStart} AND now()
|
||||
AND ( -- filter for transactions that modify a position
|
||||
it.plaid_type IN ('buy', 'sell', 'transfer')
|
||||
OR it.finicity_transaction_id IS NOT NULL
|
||||
)
|
||||
GROUP BY
|
||||
1, 2
|
||||
|
|
|
@ -153,13 +153,7 @@ export class AccountConnectionService implements IAccountConnectionService {
|
|||
])
|
||||
|
||||
this.logger.info(
|
||||
`Disconnected connection id=${connection.id} type=${
|
||||
connection.type
|
||||
} provider_connection_id=${
|
||||
connection.type === 'plaid'
|
||||
? connection.plaidItemId
|
||||
: connection.finicityInstitutionId
|
||||
}`
|
||||
`Disconnected connection id=${connection.id} type=${connection.type} provider_connection_id=${connection.plaidItemId}`
|
||||
)
|
||||
|
||||
return connection
|
||||
|
@ -182,13 +176,7 @@ export class AccountConnectionService implements IAccountConnectionService {
|
|||
])
|
||||
|
||||
this.logger.info(
|
||||
`Reconnected connection id=${connection.id} type=${
|
||||
connection.type
|
||||
} provider_connection_id=${
|
||||
connection.type === 'plaid'
|
||||
? connection.plaidItemId
|
||||
: connection.finicityInstitutionId
|
||||
}`
|
||||
`Reconnected connection id=${connection.id} type=${connection.type} provider_connection_id=${connection.plaidItemId}`
|
||||
)
|
||||
|
||||
return connection
|
||||
|
@ -213,13 +201,7 @@ export class AccountConnectionService implements IAccountConnectionService {
|
|||
})
|
||||
|
||||
this.logger.info(
|
||||
`Deleted connection id=${deletedConnection.id} type=${
|
||||
connection.type
|
||||
} provider_connection_id=${
|
||||
connection.type === 'plaid'
|
||||
? connection.plaidItemId
|
||||
: connection.finicityInstitutionId
|
||||
}`
|
||||
`Deleted connection id=${deletedConnection.id} type=${connection.type} provider_connection_id=${connection.plaidItemId}`
|
||||
)
|
||||
|
||||
return deletedConnection
|
||||
|
|
|
@ -246,7 +246,6 @@ export class AccountQueryService implements IAccountQueryService {
|
|||
(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
|
||||
1, 2
|
||||
|
|
|
@ -194,10 +194,6 @@ export const AccountUpdateSchema = z.discriminatedUnion('provider', [
|
|||
provider: z.literal('plaid'),
|
||||
data: ProviderAccountUpdateSchema,
|
||||
}),
|
||||
z.object({
|
||||
provider: z.literal('finicity'),
|
||||
data: ProviderAccountUpdateSchema,
|
||||
}),
|
||||
z.object({
|
||||
provider: z.literal('user'),
|
||||
data: UserAccountUpdateSchema,
|
||||
|
|
|
@ -312,9 +312,6 @@ export class InsightService implements IInsightService {
|
|||
{
|
||||
plaidSubtype: 'dividend',
|
||||
},
|
||||
{
|
||||
finicityInvestmentTransactionType: 'dividend',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
@ -649,9 +646,6 @@ export class InsightService implements IInsightService {
|
|||
WHEN plaid_type IN ('fixed income') THEN 'fixed_income'
|
||||
WHEN plaid_type IN ('cash', 'loan') THEN 'cash'
|
||||
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'
|
||||
END AS "asset_class"
|
||||
FROM
|
||||
|
@ -705,9 +699,6 @@ export class InsightService implements IInsightService {
|
|||
WHEN s.plaid_type IN ('fixed income') THEN 'fixed_income'
|
||||
WHEN s.plaid_type IN ('cash', 'loan') THEN 'cash'
|
||||
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'
|
||||
END AS "category"
|
||||
) x ON TRUE
|
||||
|
@ -750,7 +741,6 @@ export class InsightService implements IInsightService {
|
|||
(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
|
||||
AND (a.start_date is NULL OR it.date >= a.start_date)
|
||||
|
@ -854,9 +844,6 @@ export class InsightService implements IInsightService {
|
|||
WHEN plaid_type IN ('fixed income') THEN 'bonds'
|
||||
WHEN plaid_type IN ('cash', 'loan') THEN 'cash'
|
||||
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'
|
||||
END AS "asset_type"
|
||||
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 './finicity'
|
||||
export * from './teller'
|
||||
export * from './vehicle'
|
||||
export * from './property'
|
||||
|
|
|
@ -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)) {
|
||||
return new PolygonTicker('options', `O:${symbol}`)
|
||||
}
|
||||
|
|
|
@ -42,7 +42,6 @@ export type SyncConnectionOptions =
|
|||
type: 'plaid'
|
||||
products?: Array<'transactions' | 'investment-transactions' | 'holdings' | 'liabilities'>
|
||||
}
|
||||
| { type: 'finicity'; initialSync?: boolean }
|
||||
| { type: 'teller'; initialSync?: boolean }
|
||||
|
||||
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 SyncInstitutionQueue = IQueue<
|
||||
{},
|
||||
'sync-finicity-institutions' | 'sync-plaid-institutions' | 'sync-teller-institutions'
|
||||
'sync-plaid-institutions' | 'sync-teller-institutions'
|
||||
>
|
||||
export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>
|
||||
|
||||
|
|
|
@ -71,7 +71,6 @@ export class InMemoryQueueFactory implements IQueueFactory {
|
|||
private readonly ignoreJobNames: string[] = [
|
||||
'sync-all-securities',
|
||||
'sync-plaid-institutions',
|
||||
'sync-finicity-institutions',
|
||||
'trial-reminders',
|
||||
'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 DbUtil from './db-utils'
|
||||
export * as FinicityUtil from './finicity-utils'
|
||||
export * as PlaidUtil from './plaid-utils'
|
||||
export * as TellerUtil from './teller-utils'
|
||||
export * as ErrorUtil from './error-utils'
|
||||
|
|
|
@ -12,13 +12,7 @@ export type { Transaction }
|
|||
|
||||
export type TransactionEnriched = Omit<
|
||||
Transaction,
|
||||
| 'plaidTransactionId'
|
||||
| 'plaidCategory'
|
||||
| 'plaidCategoryId'
|
||||
| 'plaidPersonalFinanceCategory'
|
||||
| 'finicityTransactionId'
|
||||
| 'finicityType'
|
||||
| 'finicityCategorization'
|
||||
'plaidTransactionId' | 'plaidCategory' | 'plaidCategoryId' | 'plaidPersonalFinanceCategory'
|
||||
> & {
|
||||
type: TransactionType
|
||||
userId: User['id']
|
||||
|
|
|
@ -39,7 +39,6 @@
|
|||
"@casl/ability": "^6.3.2",
|
||||
"@casl/prisma": "^1.4.1",
|
||||
"@fast-csv/format": "^4.3.5",
|
||||
"@finicity/connect-web-sdk": "^1.0.0-rc.4",
|
||||
"@headlessui/react": "^1.7.2",
|
||||
"@hookform/resolvers": "^2.9.6",
|
||||
"@polygon.io/client-js": "^6.0.6",
|
||||
|
|
|
@ -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";
|
|
@ -42,7 +42,6 @@ enum AccountSyncStatus {
|
|||
|
||||
enum AccountConnectionType {
|
||||
plaid
|
||||
finicity
|
||||
teller
|
||||
}
|
||||
|
||||
|
@ -65,11 +64,6 @@ model AccountConnection {
|
|||
plaidError Json? @map("plaid_error")
|
||||
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
|
||||
tellerAccessToken String? @map("teller_access_token")
|
||||
tellerEnrollmentId String? @map("teller_enrollment_id")
|
||||
|
@ -108,7 +102,6 @@ enum AccountCategory {
|
|||
enum AccountProvider {
|
||||
user
|
||||
plaid
|
||||
finicity
|
||||
teller
|
||||
}
|
||||
|
||||
|
@ -155,11 +148,6 @@ model Account {
|
|||
plaidSubtype String? @map("plaid_subtype")
|
||||
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
|
||||
tellerAccountId String? @map("teller_account_id")
|
||||
tellerType String? @map("teller_type")
|
||||
|
@ -184,7 +172,6 @@ model Account {
|
|||
investmentTransactions InvestmentTransaction[]
|
||||
|
||||
@@unique([accountConnectionId, plaidAccountId])
|
||||
@@unique([accountConnectionId, finicityAccountId])
|
||||
@@unique([accountConnectionId, tellerAccountId])
|
||||
@@index([accountConnectionId])
|
||||
@@index([userId])
|
||||
|
@ -215,9 +202,6 @@ model Holding {
|
|||
// plaid data
|
||||
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")
|
||||
}
|
||||
|
||||
|
@ -250,17 +234,13 @@ model InvestmentTransaction {
|
|||
currencyCode String @default("USD") @map("currency_code")
|
||||
|
||||
// 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"))
|
||||
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 ELSE 'other'::\"InvestmentTransactionCategory\"\nEND"))
|
||||
|
||||
// plaid data
|
||||
plaidInvestmentTransactionId String? @unique @map("plaid_investment_transaction_id")
|
||||
plaidType String? @map("plaid_type")
|
||||
plaidSubtype String? @map("plaid_subtype")
|
||||
|
||||
// finicity data
|
||||
finicityTransactionId String? @unique @map("finicity_transaction_id")
|
||||
finicityInvestmentTransactionType String? @map("finicity_investment_transaction_type")
|
||||
|
||||
@@index([accountId, date])
|
||||
@@map("investment_transaction")
|
||||
}
|
||||
|
@ -283,18 +263,10 @@ model Security {
|
|||
plaidType String? @map("plaid_type")
|
||||
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[]
|
||||
investmentTransactions InvestmentTransaction[]
|
||||
pricing SecurityPricing[]
|
||||
|
||||
@@unique([finicitySecurityId, finicitySecurityIdType])
|
||||
@@map("security")
|
||||
}
|
||||
|
||||
|
@ -340,7 +312,7 @@ model Transaction {
|
|||
currencyCode String @default("USD") @map("currency_code")
|
||||
pending Boolean @default(false)
|
||||
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(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 (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)"))
|
||||
categoryUser String? @map("category_user")
|
||||
excluded Boolean @default(false)
|
||||
|
||||
|
@ -355,11 +327,6 @@ model Transaction {
|
|||
plaidCategoryId String? @map("plaid_category_id")
|
||||
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
|
||||
tellerTransactionId String? @unique @map("teller_transaction_id")
|
||||
tellerType String? @map("teller_type")
|
||||
|
@ -442,10 +409,6 @@ model User {
|
|||
stripeCurrentPeriodEnd DateTime? @map("stripe_current_period_end") @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
|
||||
plaidLinkToken String? @map("plaid_link_token") // temporary token stored to maintain state across browsers
|
||||
|
||||
|
@ -492,7 +455,6 @@ model Institution {
|
|||
|
||||
enum Provider {
|
||||
PLAID
|
||||
FINICITY
|
||||
TELLER
|
||||
}
|
||||
|
||||
|
|
|
@ -13,18 +13,12 @@ async function main() {
|
|||
{
|
||||
id: 1,
|
||||
name: 'Capital One',
|
||||
providers: [
|
||||
{ provider: 'PLAID', providerId: 'ins_9', rank: 1 },
|
||||
{ provider: 'FINICITY', providerId: '170778' },
|
||||
],
|
||||
providers: [{ provider: 'PLAID', providerId: 'ins_9', rank: 1 }],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Discover Bank',
|
||||
providers: [
|
||||
{ provider: 'PLAID', providerId: 'ins_33' },
|
||||
{ provider: 'FINICITY', providerId: '13796', rank: 1 },
|
||||
],
|
||||
providers: [{ provider: 'PLAID', providerId: 'ins_33' }],
|
||||
},
|
||||
]
|
||||
|
||||
|
|
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 PolygonTestData from './polygon'
|
||||
|
|
|
@ -19,7 +19,6 @@
|
|||
"@maybe-finance/client/features": ["libs/client/features/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/finicity-api": ["libs/finicity-api/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/shared": ["libs/shared/src/index.ts"],
|
||||
|
|
|
@ -230,30 +230,6 @@
|
|||
"tags": ["scope:app"],
|
||||
"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": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/server",
|
||||
|
|
|
@ -1765,11 +1765,6 @@
|
|||
lodash.isundefined "^3.0.1"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
"@finicity/connect-web-sdk@^1.0.0-rc.4":
|
||||
version "1.0.0-rc.4"
|
||||
resolved "https://registry.yarnpkg.com/@finicity/connect-web-sdk/-/connect-web-sdk-1.0.0-rc.4.tgz#e8ec00b150a82fcc6e4adf7567e7b0d7b592f808"
|
||||
integrity sha512-rt69OxN1KygXSPVlJyZrEFMpe614uEuNBXTg96wZ5kOWmCRVUOy4X3E7244iGV/Llgi3czSmz9TtMxwIMKibNA==
|
||||
|
||||
"@gar/promisify@^1.0.1":
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue