1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-08 15:05:22 +02:00

Merge pull request #157 from MichaelDeBoey/remove-Finicity-usage

feat: remove Finicity usage
This commit is contained in:
Josh Pigford 2024-01-19 20:17:13 -06:00 committed by GitHub
commit 9ebaa29116
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 297 additions and 22969 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

@ -6,11 +6,11 @@
## Backstory
We spent the better part of 2021/2022 building a personal finance + wealth management app called Maybe. Very full-featured, including an "Ask an Advisor" feature which connected users with an actual CFP/CFA to help them with their finances (all included in your subscription).
We spent the better part of 2021/2022 building a personal finance + wealth management app called, Maybe. Very full-featured, including an "Ask an Advisor" feature which connected users with an actual CFP/CFA to help them with their finances (all included in your subscription).
The business end of things didn't work out and so we shut things down mid-2023.
The business end of things didn't work out, and so we shut things down mid-2023.
We spent the better part of $1,000,000 building the app (employees + contractors, data providers/services, infrastructure, etc).
We spent the better part of $1,000,000 building the app (employees + contractors, data providers/services, infrastructure, etc.).
We're now reviving the product as a fully open-source project. The goal is to let you run the app yourself, for free, and use it to manage your own finances and eventually offer a hosted version of the app for a small monthly fee.
@ -53,14 +53,14 @@ To enable transactional emails, you'll need to create a [Postmark](https://postm
Maybe uses [Teller](https://teller.io/) for connecting financial accounts. To get started with Teller, you'll need to create an account. Once you've created an account:
- Add your Teller application id to your `.env` file (`NEXT_PUBLIC_TELLER_APP_ID`).
- Download your authentication certificates from Teller, create a `certs` folder in the root of the project, and place your certs in that directory. You should have both a `certificate.pem` and `private_key.pem`. **NEVER** check these files into source control, the `.gitignore` file will prevent the `certs/` directory from being added, but please double check.
- Download your authentication certificates from Teller, create a `certs` folder in the root of the project, and place your certs in that directory. You should have both a `certificate.pem` and `private_key.pem`. **NEVER** check these files into source control, the `.gitignore` file will prevent the `certs/` directory from being added, but please double-check.
- Set your `NEXT_PUBLIC_TELLER_ENV` and `NX_TELLER_ENV` to your desired environment. The default is `sandbox` which allows for testing with mock data. The login credentials for the sandbox environment are `username` and `password`. To connect to real financial accounts, you'll need to use the `development` environment.
- Webhooks are not implemented yet, but you can populate the `NX_TELLER_SIGNING_SECRET` with the value from your Teller account.
- We highly recommend checking out the [Teller docs](https://teller.io/docs) for more info.
Then run the following yarn commands:
```
```shell
yarn install
yarn run dev:services:all
yarn prisma:migrate:dev
@ -95,7 +95,7 @@ To contribute, please see our [contribution guide](https://github.com/maybe-fina
## High-priority issues
The biggest focus at the moment is on getting the app functional without some previously key external services (namely Plaid and Finicity).
The biggest focus at the moment is on getting the app functional without some previously key external services (namely Plaid).
You can view the current [high-priority issues here](https://github.com/maybe-finance/maybe/issues?q=is:issue+is:open+label:%22high+priority%22). Those are the most impactful issues to tackle first.

View file

@ -35,7 +35,6 @@ import {
accountRollupRouter,
valuationsRouter,
institutionsRouter,
finicityRouter,
tellerRouter,
transactionsRouter,
holdingsRouter,
@ -110,7 +109,6 @@ app.use(
app.use('/v1/stripe', express.raw({ type: 'application/json' }))
app.use(express.urlencoded({ extended: true }))
app.use(express.json({ limit: '50mb' })) // Finicity sends large response bodies for webhooks
// =========================================
// API ⬇️
@ -158,7 +156,6 @@ app.use('/v1', validateAuthJwt)
app.use('/v1/users', usersRouter)
app.use('/v1/e2e', e2eRouter)
app.use('/v1/plaid', plaidRouter)
app.use('/v1/finicity', finicityRouter)
app.use('/v1/teller', tellerRouter)
app.use('/v1/accounts', accountsRouter)
app.use('/v1/account-rollup', accountRollupRouter)

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

@ -99,20 +99,6 @@ router.post(
})
)
router.post(
'/:id/finicity/fix-connect',
endpoint.create({
resolve: async ({ ctx, req }) => {
const { link } = await ctx.finicityService.generateFixConnectUrl(
ctx.user!.id,
+req.params.id
)
return { link }
},
})
)
router.post(
'/:id/sync',
endpoint.create({

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'

View file

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

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

@ -112,11 +112,6 @@ syncInstitutionQueue.process(
async () => await institutionService.sync('PLAID')
)
syncInstitutionQueue.process(
'sync-finicity-institutions',
async () => await institutionService.sync('FINICITY')
)
syncInstitutionQueue.process(
'sync-teller-institutions',
async () => await institutionService.sync('TELLER')
@ -131,15 +126,6 @@ syncInstitutionQueue.add(
}
)
syncInstitutionQueue.add(
'sync-finicity-institutions',
{},
{
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
jobId: Date.now().toString(),
}
)
syncInstitutionQueue.add(
'sync-teller-institutions',
{},

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

@ -72,18 +72,10 @@ const brokerages: GridImage[] = [
{
src: 'fidelity.png',
alt: 'Fidelity',
institution: {
provider: 'FINICITY',
providerId: '9913',
},
},
{
src: 'vanguard.png',
alt: 'Vanguard',
institution: {
provider: 'FINICITY',
providerId: '3078',
},
},
{
src: 'wealthfront.png',

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

@ -153,13 +153,7 @@ export class AccountConnectionService implements IAccountConnectionService {
])
this.logger.info(
`Disconnected connection id=${connection.id} type=${
connection.type
} provider_connection_id=${
connection.type === 'plaid'
? connection.plaidItemId
: connection.finicityInstitutionId
}`
`Disconnected connection id=${connection.id} type=${connection.type} provider_connection_id=${connection.plaidItemId}`
)
return connection
@ -182,13 +176,7 @@ export class AccountConnectionService implements IAccountConnectionService {
])
this.logger.info(
`Reconnected connection id=${connection.id} type=${
connection.type
} provider_connection_id=${
connection.type === 'plaid'
? connection.plaidItemId
: connection.finicityInstitutionId
}`
`Reconnected connection id=${connection.id} type=${connection.type} provider_connection_id=${connection.plaidItemId}`
)
return connection
@ -213,13 +201,7 @@ export class AccountConnectionService implements IAccountConnectionService {
})
this.logger.info(
`Deleted connection id=${deletedConnection.id} type=${
connection.type
} provider_connection_id=${
connection.type === 'plaid'
? connection.plaidItemId
: connection.finicityInstitutionId
}`
`Deleted connection id=${deletedConnection.id} type=${connection.type} provider_connection_id=${connection.plaidItemId}`
)
return deletedConnection

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,9 +646,6 @@ export class InsightService implements IInsightService {
WHEN plaid_type IN ('fixed income') THEN 'fixed_income'
WHEN plaid_type IN ('cash', 'loan') THEN 'cash'
WHEN plaid_type IN ('cryptocurrency') THEN 'crypto'
-- finicity
WHEN finicity_type IN ('EQUITY', 'ETF', 'MUTUALFUND', 'STOCKINFO', 'MFINFO') THEN 'stocks'
WHEN finicity_type IN ('BOND') THEN 'fixed_income'
ELSE 'other'
END AS "asset_class"
FROM
@ -705,9 +699,6 @@ export class InsightService implements IInsightService {
WHEN s.plaid_type IN ('fixed income') THEN 'fixed_income'
WHEN s.plaid_type IN ('cash', 'loan') THEN 'cash'
WHEN s.plaid_type IN ('cryptocurrency') THEN 'crypto'
-- finicity
WHEN s.finicity_type IN ('EQUITY', 'ETF', 'MUTUALFUND', 'STOCKINFO', 'MFINFO') THEN 'stocks'
WHEN s.finicity_type IN ('BOND') THEN 'fixed_income'
ELSE 'other'
END AS "category"
) x ON TRUE
@ -750,7 +741,6 @@ export class InsightService implements IInsightService {
(it.plaid_type = 'cash' AND it.plaid_subtype IN ('contribution', 'deposit', 'withdrawal'))
OR (it.plaid_type = 'transfer' AND it.plaid_subtype IN ('transfer', 'send', 'request'))
OR (it.plaid_type = 'buy' AND it.plaid_subtype IN ('contribution'))
OR (it.finicity_transaction_id IS NOT NULL AND it.finicity_investment_transaction_type IN ('contribution', 'deposit', 'transfer'))
)
-- Exclude any contributions made prior to the start date since balances will be 0
AND (a.start_date is NULL OR it.date >= a.start_date)
@ -854,9 +844,6 @@ export class InsightService implements IInsightService {
WHEN plaid_type IN ('fixed income') THEN 'bonds'
WHEN plaid_type IN ('cash', 'loan') THEN 'cash'
WHEN plaid_type IN ('cryptocurrency') THEN 'crypto'
-- finicity
WHEN finicity_type IN ('EQUITY', 'ETF', 'MUTUALFUND', 'STOCKINFO', 'MFINFO') THEN 'stocks'
WHEN finicity_type IN ('BOND') THEN 'bonds'
ELSE 'other'
END AS "asset_type"
FROM

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,6 @@ export function getPolygonTicker({
}
}
// Finicity's `type` field isn't really helpful here, so we'll just use isOptionTicker
if (MarketUtil.isOptionTicker(symbol)) {
return new PolygonTicker('options', `O:${symbol}`)
}

View file

@ -42,7 +42,6 @@ export type SyncConnectionOptions =
type: 'plaid'
products?: Array<'transactions' | 'investment-transactions' | 'holdings' | 'liabilities'>
}
| { type: 'finicity'; initialSync?: boolean }
| { type: 'teller'; initialSync?: boolean }
export type SyncConnectionQueueJobData = {
@ -72,7 +71,7 @@ export type SyncSecurityQueue = IQueue<SyncSecurityQueueJobData, 'sync-all-secur
export type PurgeUserQueue = IQueue<{ userId: User['id'] }, 'purge-user'>
export type SyncInstitutionQueue = IQueue<
{},
'sync-finicity-institutions' | 'sync-plaid-institutions' | 'sync-teller-institutions'
'sync-plaid-institutions' | 'sync-teller-institutions'
>
export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>

View file

@ -71,7 +71,6 @@ export class InMemoryQueueFactory implements IQueueFactory {
private readonly ignoreJobNames: string[] = [
'sync-all-securities',
'sync-plaid-institutions',
'sync-finicity-institutions',
'trial-reminders',
'send-email',
]

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

@ -12,13 +12,7 @@ export type { Transaction }
export type TransactionEnriched = Omit<
Transaction,
| 'plaidTransactionId'
| 'plaidCategory'
| 'plaidCategoryId'
| 'plaidPersonalFinanceCategory'
| 'finicityTransactionId'
| 'finicityType'
| 'finicityCategorization'
'plaidTransactionId' | 'plaidCategory' | 'plaidCategoryId' | 'plaidPersonalFinanceCategory'
> & {
type: TransactionType
userId: User['id']

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

@ -0,0 +1,79 @@
-- AlterTable
ALTER TABLE "investment_transaction"
RENAME COLUMN "category" TO "category_old";
DROP VIEW IF EXISTS holdings_enriched;
ALTER TABLE "investment_transaction"
ADD COLUMN "category" "InvestmentTransactionCategory" NOT NULL GENERATED ALWAYS AS (
CASE
WHEN "plaid_type" = 'buy' THEN 'buy'::"InvestmentTransactionCategory"
WHEN "plaid_type" = 'sell' THEN 'sell'::"InvestmentTransactionCategory"
WHEN "plaid_subtype" IN ('dividend', 'qualified dividend', 'non-qualified dividend') THEN 'dividend'::"InvestmentTransactionCategory"
WHEN "plaid_subtype" IN ('non-resident tax', 'tax', 'tax withheld') THEN 'tax'::"InvestmentTransactionCategory"
WHEN "plaid_type" = 'fee' OR "plaid_subtype" IN ('account fee', 'legal fee', 'management fee', 'margin expense', 'transfer fee', 'trust fee') THEN 'fee'::"InvestmentTransactionCategory"
WHEN "plaid_type" = 'cash' THEN 'transfer'::"InvestmentTransactionCategory"
WHEN "plaid_type" = 'cancel' THEN 'cancel'::"InvestmentTransactionCategory"
ELSE 'other'::"InvestmentTransactionCategory"
END
) STORED;
CREATE OR REPLACE VIEW holdings_enriched AS (
SELECT
h.id,
h.account_id,
h.security_id,
h.quantity,
COALESCE(pricing_latest.price_close * h.quantity * COALESCE(s.shares_per_contract, 1), h.value) AS "value",
COALESCE(h.cost_basis, tcb.cost_basis * h.quantity) AS "cost_basis",
COALESCE(h.cost_basis / h.quantity / COALESCE(s.shares_per_contract, 1), tcb.cost_basis) AS "cost_basis_per_share",
pricing_latest.price_close AS "price",
pricing_prev.price_close AS "price_prev",
h.excluded
FROM
holding h
INNER JOIN security s ON s.id = h.security_id
-- latest security pricing
LEFT JOIN LATERAL (
SELECT
price_close
FROM
security_pricing
WHERE
security_id = h.security_id
ORDER BY
date DESC
LIMIT 1
) pricing_latest ON true
-- previous security pricing (for computing daily ∆)
LEFT JOIN LATERAL (
SELECT
price_close
FROM
security_pricing
WHERE
security_id = h.security_id
ORDER BY
date DESC
LIMIT 1
OFFSET 1
) pricing_prev ON true
-- calculate cost basis from transactions
LEFT JOIN (
SELECT
it.account_id,
it.security_id,
SUM(it.quantity * it.price) / SUM(it.quantity) AS cost_basis
FROM
investment_transaction it
WHERE
it.plaid_type = 'buy'
AND it.quantity > 0
GROUP BY
it.account_id,
it.security_id
) tcb ON tcb.account_id = h.account_id AND tcb.security_id = s.id
);
ALTER TABLE "investment_transaction" DROP COLUMN "category_old";

View file

@ -0,0 +1,91 @@
-- AlterTable
ALTER TABLE "transaction"
RENAME COLUMN "category" TO "category_old";
ALTER TABLE "transaction"
RENAME COLUMN "category_user" TO "category_user_old";
DROP VIEW IF EXISTS transactions_enriched;
ALTER TABLE "transaction" ADD COLUMN "category_user" TEXT;
ALTER TABLE "transaction"
ADD COLUMN "category" TEXT NOT NULL GENERATED ALWAYS AS (COALESCE(category_user,
CASE
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'INCOME'::text) THEN 'Income'::text
WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = ANY (ARRAY['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text])) THEN 'Housing Payments'::text
WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = 'LOAN_PAYMENTS_CAR_PAYMENT'::text) THEN 'Vehicle Payments'::text
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'LOAN_PAYMENTS'::text) THEN 'Other Payments'::text
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'HOME_IMPROVEMENT'::text) THEN 'Home Improvement'::text
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'GENERAL_MERCHANDISE'::text) THEN 'Shopping'::text
WHEN (((plaid_personal_finance_category ->> 'primary'::text) = 'RENT_AND_UTILITIES'::text) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'RENT_AND_UTILITIES_RENT'::text)) THEN 'Utilities'::text
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'FOOD_AND_DRINK'::text) THEN 'Food and Drink'::text
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRANSPORTATION'::text) THEN 'Transportation'::text
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRAVEL'::text) THEN 'Travel'::text
WHEN (((plaid_personal_finance_category ->> 'primary'::text) = ANY (ARRAY['PERSONAL_CARE'::text, 'MEDICAL'::text])) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'MEDICAL_VETERINARY_SERVICES'::text)) THEN 'Health'::text
WHEN (teller_category = 'income'::text) THEN 'Income'::text
WHEN (teller_category = 'home'::text) THEN 'Home Improvement'::text
WHEN (teller_category = ANY (ARRAY['phone'::text, 'utilities'::text])) THEN 'Utilities'::text
WHEN (teller_category = ANY (ARRAY['dining'::text, 'bar'::text, 'groceries'::text])) THEN 'Food and Drink'::text
WHEN (teller_category = ANY (ARRAY['clothing'::text, 'entertainment'::text, 'shopping'::text, 'electronics'::text, 'software'::text, 'sport'::text])) THEN 'Shopping'::text
WHEN (teller_category = ANY (ARRAY['transportation'::text, 'fuel'::text])) THEN 'Transportation'::text
WHEN (teller_category = ANY (ARRAY['accommodation'::text, 'transport'::text])) THEN 'Travel'::text
WHEN (teller_category = 'health'::text) THEN 'Health'::text
WHEN (teller_category = ANY (ARRAY['loan'::text, 'tax'::text, 'insurance'::text, 'office'::text])) THEN 'Other Payments'::text
ELSE 'Other'::text
END)) STORED;
CREATE OR REPLACE VIEW transactions_enriched AS (
SELECT
t.id,
t.created_at as "createdAt",
t.updated_at as "updatedAt",
t.name,
t.account_id as "accountId",
t.date,
t.flow,
COALESCE(
t.type_user,
CASE
-- no matching transaction
WHEN t.match_id IS NULL THEN (
CASE
t.flow
WHEN 'INFLOW' THEN (
CASE
a.classification
WHEN 'asset' THEN 'INCOME' :: "TransactionType"
WHEN 'liability' THEN 'PAYMENT' :: "TransactionType"
END
)
WHEN 'OUTFLOW' THEN 'EXPENSE' :: "TransactionType"
END
) -- has matching transaction
ELSE (
CASE
a.classification
WHEN 'asset' THEN 'TRANSFER' :: "TransactionType"
WHEN 'liability' THEN 'PAYMENT' :: "TransactionType"
END
)
END
) AS "type",
t.type_user as "typeUser",
t.amount,
t.currency_code as "currencyCode",
t.pending,
t.merchant_name as "merchantName",
t.category,
t.category_user as "categoryUser",
t.excluded,
t.match_id as "matchId",
COALESCE(ac.user_id, a.user_id) as "userId",
a.classification as "accountClassification",
a.type as "accountType"
FROM
transaction t
inner join account a on a.id = t.account_id
left join account_connection ac on a.account_connection_id = ac.id
);
ALTER TABLE "transaction" DROP COLUMN "category_old";
ALTER TABLE "transaction" DROP COLUMN "category_user_old";

View file

@ -0,0 +1,107 @@
/*
Warnings:
- The values [finicity] on the enum `AccountConnectionType` will be removed. If these variants are still used in the database, this will fail.
- The values [finicity] on the enum `AccountProvider` will be removed. If these variants are still used in the database, this will fail.
- The values [FINICITY] on the enum `Provider` will be removed. If these variants are still used in the database, this will fail.
- You are about to drop the column `finicity_account_id` on the `account` table. All the data in the column will be lost.
- You are about to drop the column `finicity_detail` on the `account` table. All the data in the column will be lost.
- You are about to drop the column `finicity_type` on the `account` table. All the data in the column will be lost.
- You are about to drop the column `finicity_error` on the `account_connection` table. All the data in the column will be lost.
- You are about to drop the column `finicity_institution_id` on the `account_connection` table. All the data in the column will be lost.
- You are about to drop the column `finicity_institution_login_id` on the `account_connection` table. All the data in the column will be lost.
- You are about to drop the column `finicity_position_id` on the `holding` table. All the data in the column will be lost.
- You are about to drop the column `finicity_investment_transaction_type` on the `investment_transaction` table. All the data in the column will be lost.
- You are about to drop the column `finicity_transaction_id` on the `investment_transaction` table. All the data in the column will be lost.
- You are about to drop the column `finicity_asset_class` on the `security` table. All the data in the column will be lost.
- You are about to drop the column `finicity_fi_asset_class` on the `security` table. All the data in the column will be lost.
- You are about to drop the column `finicity_security_id` on the `security` table. All the data in the column will be lost.
- You are about to drop the column `finicity_security_id_type` on the `security` table. All the data in the column will be lost.
- You are about to drop the column `finicity_type` on the `security` table. All the data in the column will be lost.
- You are about to drop the column `finicity_categorization` on the `transaction` table. All the data in the column will be lost.
- You are about to drop the column `finicity_transaction_id` on the `transaction` table. All the data in the column will be lost.
- You are about to drop the column `finicity_type` on the `transaction` table. All the data in the column will be lost.
- You are about to drop the column `finicity_customer_id` on the `user` table. All the data in the column will be lost.
- You are about to drop the column `finicity_username` on the `user` table. All the data in the column will be lost.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "AccountConnectionType_new" AS ENUM ('plaid', 'teller');
ALTER TABLE "account_connection" ALTER COLUMN "type" TYPE "AccountConnectionType_new" USING ("type"::text::"AccountConnectionType_new");
ALTER TYPE "AccountConnectionType" RENAME TO "AccountConnectionType_old";
ALTER TYPE "AccountConnectionType_new" RENAME TO "AccountConnectionType";
DROP TYPE "AccountConnectionType_old";
COMMIT;
-- AlterEnum
BEGIN;
CREATE TYPE "AccountProvider_new" AS ENUM ('user', 'plaid', 'teller');
ALTER TABLE "account" ALTER COLUMN "provider" TYPE "AccountProvider_new" USING ("provider"::text::"AccountProvider_new");
ALTER TYPE "AccountProvider" RENAME TO "AccountProvider_old";
ALTER TYPE "AccountProvider_new" RENAME TO "AccountProvider";
DROP TYPE "AccountProvider_old";
COMMIT;
-- AlterEnum
BEGIN;
CREATE TYPE "Provider_new" AS ENUM ('PLAID', 'TELLER');
ALTER TABLE "provider_institution" ALTER COLUMN "provider" TYPE "Provider_new" USING ("provider"::text::"Provider_new");
ALTER TYPE "Provider" RENAME TO "Provider_old";
ALTER TYPE "Provider_new" RENAME TO "Provider";
DROP TYPE "Provider_old";
COMMIT;
-- DropIndex
DROP INDEX "account_account_connection_id_finicity_account_id_key";
-- DropIndex
DROP INDEX "holding_finicity_position_id_key";
-- DropIndex
DROP INDEX "investment_transaction_finicity_transaction_id_key";
-- DropIndex
DROP INDEX "security_finicity_security_id_finicity_security_id_type_key";
-- DropIndex
DROP INDEX "transaction_finicity_transaction_id_key";
-- DropIndex
DROP INDEX "user_finicity_customer_id_key";
-- DropIndex
DROP INDEX "user_finicity_username_key";
-- AlterTable
ALTER TABLE "account" DROP COLUMN "finicity_account_id",
DROP COLUMN "finicity_detail",
DROP COLUMN "finicity_type";
-- AlterTable
ALTER TABLE "account_connection" DROP COLUMN "finicity_error",
DROP COLUMN "finicity_institution_id",
DROP COLUMN "finicity_institution_login_id";
-- AlterTable
ALTER TABLE "holding" DROP COLUMN "finicity_position_id";
-- AlterTable
ALTER TABLE "investment_transaction" DROP COLUMN "finicity_investment_transaction_type",
DROP COLUMN "finicity_transaction_id";
-- AlterTable
ALTER TABLE "security" DROP COLUMN "finicity_asset_class",
DROP COLUMN "finicity_fi_asset_class",
DROP COLUMN "finicity_security_id",
DROP COLUMN "finicity_security_id_type",
DROP COLUMN "finicity_type";
-- AlterTable
ALTER TABLE "transaction" DROP COLUMN "finicity_categorization",
DROP COLUMN "finicity_transaction_id",
DROP COLUMN "finicity_type";
-- AlterTable
ALTER TABLE "user" DROP COLUMN "finicity_customer_id",
DROP COLUMN "finicity_username";

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

@ -13,18 +13,12 @@ async function main() {
{
id: 1,
name: 'Capital One',
providers: [
{ provider: 'PLAID', providerId: 'ins_9', rank: 1 },
{ provider: 'FINICITY', providerId: '170778' },
],
providers: [{ provider: 'PLAID', providerId: 'ins_9', rank: 1 }],
},
{
id: 2,
name: 'Discover Bank',
providers: [
{ provider: 'PLAID', providerId: 'ins_33' },
{ provider: 'FINICITY', providerId: '13796', rank: 1 },
],
providers: [{ provider: 'PLAID', providerId: 'ins_33' }],
},
]

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,3 +1,2 @@
export * as FinicityTestData from './finicity'
export * as PlaidTestData from './plaid'
export * as PolygonTestData from './polygon'

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

@ -1765,11 +1765,6 @@
lodash.isundefined "^3.0.1"
lodash.uniq "^4.5.0"
"@finicity/connect-web-sdk@^1.0.0-rc.4":
version "1.0.0-rc.4"
resolved "https://registry.yarnpkg.com/@finicity/connect-web-sdk/-/connect-web-sdk-1.0.0-rc.4.tgz#e8ec00b150a82fcc6e4adf7567e7b0d7b592f808"
integrity sha512-rt69OxN1KygXSPVlJyZrEFMpe614uEuNBXTg96wZ5kOWmCRVUOy4X3E7244iGV/Llgi3czSmz9TtMxwIMKibNA==
"@gar/promisify@^1.0.1":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"