1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 07:25:19 +02:00

removed all references to Finicity

This commit is contained in:
Lorenzo Palaia 2024-01-18 22:49:27 +01:00
parent 7d46d22152
commit be4150ed20
71 changed files with 21 additions and 23055 deletions

View file

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

View file

@ -94,7 +94,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 (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.

View file

@ -35,7 +35,6 @@ import {
accountRollupRouter,
valuationsRouter,
institutionsRouter,
finicityRouter,
tellerRouter,
transactionsRouter,
holdingsRouter,
@ -44,7 +43,6 @@ import {
toolsRouter,
publicRouter,
e2eRouter,
adminRouter,
} from './routes'
import env from '../env'
@ -94,7 +92,6 @@ app.use(cors({ origin, credentials: true }))
app.options('*', cors() as RequestHandler)
app.set('view engine', 'ejs').set('views', __dirname + '/app/admin/views')
app.use('/admin', adminRouter)
app.use(
morgan(env.NX_MORGAN_LOG_LEVEL, {
@ -110,7 +107,7 @@ 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
app.use(express.json({ limit: '50mb' })) // previously Finicity sends large response bodies for webhooks, kept the limit for now
// =========================================
// API ⬇️
@ -158,7 +155,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)

View file

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

View file

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

View file

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

View file

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

View file

@ -1,33 +0,0 @@
import { Router } from 'express'
import { auth, claimCheck } from 'express-openid-connect'
import { createBullBoard } from '@bull-board/api'
import { BullAdapter } from '@bull-board/api/bullAdapter'
import { ExpressAdapter } from '@bull-board/express'
import { AuthUtil, BullQueue } from '@maybe-finance/server/shared'
import { SharedType } from '@maybe-finance/shared'
import { queueService } from '../lib/endpoint'
import env from '../../env'
import { validateAuthJwt } from '../middleware'
const router = Router()
const serverAdapter = new ExpressAdapter().setBasePath('/admin/bullmq')
createBullBoard({
queues: queueService.allQueues
.filter((q): q is BullQueue => q instanceof BullQueue)
.map((q) => new BullAdapter(q.queue)),
serverAdapter,
})
router.get('/', validateAuthJwt, (req, res) => {
res.render('pages/dashboard', {
user: req.user?.name,
role: 'Admin',
})
})
// Visit /admin/bullmq to see BullMQ Dashboard
router.use('/bullmq', validateAuthJwt, serverAdapter.getRouter())
export default router

View file

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

View file

@ -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'
@ -15,4 +14,3 @@ export { default as plansRouter } from './plans.router'
export { default as toolsRouter } from './tools.router'
export { default as publicRouter } from './public.router'
export { default as e2eRouter } from './e2e.router'
export { default as adminRouter } from './admin.router'

View file

@ -27,10 +27,9 @@ router.post(
resolve: async ({ ctx }) => {
ctx.ability.throwUnlessCan('update', 'Institution')
// Sync all Plaid + Finicity institutions
// Sync all Plaid institutions
await ctx.queueService.getQueue('sync-institution').addBulk([
{ name: 'sync-plaid-institutions', data: {} },
{ name: 'sync-finicity-institutions', data: {} },
])
return { success: true }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,7 +16,6 @@ import {
workerErrorHandlerService,
} from './app/lib/di'
import env from './env'
import { cleanUpOutdatedJobs } from './utils'
// Defaults from quickstart - https://docs.sentry.io/platforms/node/
Sentry.init({
@ -100,7 +99,6 @@ syncSecurityQueue.add(
{},
{
repeat: { cron: '*/5 * * * *' }, // Run every 5 minutes
jobId: Date.now().toString(),
}
)
@ -112,11 +110,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')
@ -127,16 +120,6 @@ syncInstitutionQueue.add(
{},
{
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
jobId: Date.now().toString(),
}
)
syncInstitutionQueue.add(
'sync-finicity-institutions',
{},
{
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
jobId: Date.now().toString(),
}
)
@ -145,7 +128,6 @@ syncInstitutionQueue.add(
{},
{
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
jobId: Date.now().toString(),
}
)
@ -174,11 +156,6 @@ process.on(
await workerErrorHandlerService.handleWorkersError({ variant: 'unhandled', error })
)
// Replace any jobs that have changed cron schedules and ensures only
// one repeatable jobs for each type is running
const queues = [syncSecurityQueue, syncInstitutionQueue]
cleanUpOutdatedJobs(queues)
const app = express()
app.use(cors())
@ -204,24 +181,20 @@ const server = app.listen(env.NX_PORT, () => {
logger.info(`Worker health server started on port ${env.NX_PORT}`)
})
async function onShutdown() {
function onShutdown() {
logger.info('[shutdown.start]')
await new Promise((resolve) => server.close(resolve))
server.close()
// shutdown queues
try {
await Promise.allSettled(
queueService.allQueues
.filter((q): q is BullQueue => q instanceof BullQueue)
.map((q) => q.queue.close())
)
} catch (error) {
logger.error('[shutdown.error]', error)
} finally {
Promise.allSettled(
queueService.allQueues
.filter((q): q is BullQueue => q instanceof BullQueue)
.map((q) => q.queue.close())
).finally(() => {
logger.info('[shutdown.complete]')
process.exitCode = 0
}
process.exit()
})
}
process.on('SIGINT', onShutdown)

View file

@ -1,46 +0,0 @@
import type { IQueue } from '@maybe-finance/server/shared'
import type { JobInformation } from 'bull'
export async function cleanUpOutdatedJobs(queues: IQueue[]) {
for (const queue of queues) {
const repeatedJobs = await queue.getRepeatableJobs()
const outdatedJobs = filterOutdatedJobs(repeatedJobs)
for (const job of outdatedJobs) {
await queue.removeRepeatableByKey(job.key)
}
}
}
function filterOutdatedJobs(jobs: JobInformation[]) {
const jobGroups = new Map()
jobs.forEach((job) => {
if (!jobGroups.has(job.name)) {
jobGroups.set(job.name, [])
}
jobGroups.get(job.name).push(job)
})
const mostRecentJobs = new Map()
jobGroups.forEach((group, name) => {
const mostRecentJob = group.reduce((mostRecent, current) => {
if (current.id === null) return mostRecent
const currentIdTime = current.id
const mostRecentIdTime = mostRecent ? mostRecent.id : 0
return currentIdTime > mostRecentIdTime ? current : mostRecent
}, null)
if (mostRecentJob) {
mostRecentJobs.set(name, mostRecentJob.id)
}
})
return jobs.filter((job: JobInformation) => {
const mostRecentId = mostRecentJobs.get(job.name)
return job.id === null || job.id !== mostRecentId
})
}
export default cleanUpOutdatedJobs

View file

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

View file

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

View file

@ -69,22 +69,6 @@ const brokerages: GridImage[] = [
providerId: 'ins_54',
},
},
{
src: 'fidelity.png',
alt: 'Fidelity',
institution: {
provider: 'FINICITY',
providerId: '9913',
},
},
{
src: 'vanguard.png',
alt: 'Vanguard',
institution: {
provider: 'FINICITY',
providerId: '3078',
},
},
{
src: 'wealthfront.png',
alt: 'Wealthfront',

View file

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

View file

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

View file

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

View file

@ -1,6 +1,5 @@
export * from './useAxiosWithAuth'
export * from './useDebounce'
export * from './useFinicity'
export * from './useInterval'
export * from './useLastUpdated'
export * from './useLocalStorage'

View file

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

View file

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

View file

@ -1,3 +0,0 @@
{
"presets": [["@nrwl/web/babel", { "useBuiltIns": "usage" }]]
}

View file

@ -1,18 +0,0 @@
{
"extends": ["../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

View file

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

View file

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

View file

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

View file

@ -1,2 +0,0 @@
export * from './finicity-api'
export * as FinicityTypes from './types'

View file

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

View file

@ -1,4 +0,0 @@
/** https://api-reference.finicity.com/#/rest/models/structures/authentication-response */
export type AuthenticationResponse = {
token: string
}

View file

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

View file

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

View file

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

View file

@ -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[]
}

View file

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

View file

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

View file

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

View file

@ -1,13 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View file

@ -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"]
}

View file

@ -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"
]
}

View file

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

View file

@ -156,9 +156,7 @@ export class AccountConnectionService implements IAccountConnectionService {
`Disconnected connection id=${connection.id} type=${
connection.type
} provider_connection_id=${
connection.type === 'plaid'
? connection.plaidItemId
: connection.finicityInstitutionId
connection.plaidItemId
}`
)
@ -185,9 +183,7 @@ export class AccountConnectionService implements IAccountConnectionService {
`Reconnected connection id=${connection.id} type=${
connection.type
} provider_connection_id=${
connection.type === 'plaid'
? connection.plaidItemId
: connection.finicityInstitutionId
connection.plaidItemId
}`
)
@ -216,9 +212,7 @@ export class AccountConnectionService implements IAccountConnectionService {
`Deleted connection id=${deletedConnection.id} type=${
connection.type
} provider_connection_id=${
connection.type === 'plaid'
? connection.plaidItemId
: connection.finicityInstitutionId
connection.plaidItemId
}`
)

View file

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

View file

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

View file

@ -312,9 +312,6 @@ export class InsightService implements IInsightService {
{
plaidSubtype: 'dividend',
},
{
finicityInvestmentTransactionType: 'dividend',
},
],
},
}),
@ -649,10 +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
"security"
@ -705,10 +698,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
WHERE
@ -750,7 +739,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,10 +842,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
"security"

View file

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

View file

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

View file

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

View file

@ -1,3 +0,0 @@
export * from './finicity.service'
export * from './finicity.etl'
export * from './finicity.webhook'

View file

@ -1,5 +1,4 @@
export * from './plaid'
export * from './finicity'
export * from './teller'
export * from './vehicle'
export * from './property'

View file

@ -482,7 +482,7 @@ export function getPolygonTicker({
}
}
// Finicity's `type` field isn't really helpful here, so we'll just use isOptionTicker
// Finicity was removed, so we'll just use isOptionTicker
if (MarketUtil.isOptionTicker(symbol)) {
return new PolygonTicker('options', `O:${symbol}`)
}

View file

@ -1,6 +1,6 @@
import type { AccountConnection, User, Account } from '@prisma/client'
import type { Logger } from 'winston'
import type { Job, JobOptions, JobInformation } from 'bull'
import type { Job, JobOptions } from 'bull'
import type { SharedType } from '@maybe-finance/shared'
export type IJob<T> = Pick<Job<T>, 'id' | 'name' | 'data' | 'progress'>
@ -20,8 +20,6 @@ export type IQueue<TData extends Record<string, any> = {}, TJobName extends stri
options?: { concurrency: number }
): Promise<void>
getActiveJobs(): Promise<IJob<TData>[]>
getRepeatableJobs(): Promise<JobInformation[]>
removeRepeatableByKey(key: string): Promise<void>
cancelJobs(): Promise<void>
}
@ -42,7 +40,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 +69,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'>

View file

@ -120,14 +120,6 @@ export class BullQueue<TData extends Record<string, any> = any, TJobName extends
return this.queue.getActive()
}
async getRepeatableJobs(): Promise<Queue.JobInformation[]> {
return this.queue.getRepeatableJobs()
}
async removeRepeatableByKey(key: string) {
return this.queue.removeRepeatableByKey(key)
}
async cancelJobs() {
await this.queue.pause(true, true)
await this.queue.removeJobs('*')

View file

@ -53,14 +53,6 @@ export class InMemoryQueue<
return []
}
async getRepeatableJobs() {
return []
}
async removeRepeatableByKey(_key: string) {
// no-op
}
async cancelJobs() {
// no-op
}
@ -71,7 +63,6 @@ export class InMemoryQueueFactory implements IQueueFactory {
private readonly ignoreJobNames: string[] = [
'sync-all-securities',
'sync-plaid-institutions',
'sync-finicity-institutions',
'trial-reminders',
'send-email',
]

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,6 @@ async function main() {
name: 'Capital One',
providers: [
{ provider: 'PLAID', providerId: 'ins_9', rank: 1 },
{ provider: 'FINICITY', providerId: '170778' },
],
},
{
@ -22,7 +21,6 @@ async function main() {
name: 'Discover Bank',
providers: [
{ provider: 'PLAID', providerId: 'ins_33' },
{ provider: 'FINICITY', providerId: '13796', rank: 1 },
],
},
]

File diff suppressed because it is too large Load diff

View file

@ -1 +0,0 @@
export * from './finicityTestData'

View file

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

View file

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

View file

@ -1760,11 +1760,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"