mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-08 15:05:22 +02:00
Merge branch 'main' of github.com:maybe-finance/maybe into fix-session-load
This commit is contained in:
commit
8ec404b1fc
97 changed files with 777 additions and 23295 deletions
|
@ -58,5 +58,3 @@ NX_POSTMARK_API_TOKEN=
|
|||
# for now, they are still required.
|
||||
########################################################################
|
||||
NX_PLAID_SECRET=
|
||||
NX_FINICITY_APP_KEY=
|
||||
NX_FINICITY_PARTNER_SECRET=
|
||||
|
|
15
README.md
15
README.md
|
@ -6,11 +6,11 @@
|
|||
|
||||
## Backstory
|
||||
|
||||
We spent the better part of 2021/2022 building a personal finance + wealth management app called Maybe. Very full-featured, including an "Ask an Advisor" feature which connected users with an actual CFP/CFA to help them with their finances (all included in your subscription).
|
||||
We spent the better part of 2021/2022 building a personal finance + wealth management app called, Maybe. Very full-featured, including an "Ask an Advisor" feature which connected users with an actual CFP/CFA to help them with their finances (all included in your subscription).
|
||||
|
||||
The business end of things didn't work out and so we shut things down mid-2023.
|
||||
The business end of things didn't work out, and so we shut things down mid-2023.
|
||||
|
||||
We spent the better part of $1,000,000 building the app (employees + contractors, data providers/services, infrastructure, etc).
|
||||
We spent the better part of $1,000,000 building the app (employees + contractors, data providers/services, infrastructure, etc.).
|
||||
|
||||
We're now reviving the product as a fully open-source project. The goal is to let you run the app yourself, for free, and use it to manage your own finances and eventually offer a hosted version of the app for a small monthly fee.
|
||||
|
||||
|
@ -37,7 +37,8 @@ And dozens upon dozens of smaller features.
|
|||
|
||||
This is the current state of building the app. We're actively working to make this process much more streamlined!
|
||||
|
||||
You'll need Docker installed to run the app locally.
|
||||
*You'll need Docker installed to run the app locally.*
|
||||
[Docker Desktop](https://www.docker.com/products/docker-desktop/) is an easy way to get started.
|
||||
|
||||
First, copy the `.env.example` file to `.env`:
|
||||
|
||||
|
@ -52,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
|
||||
|
@ -94,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.
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ export default function LoginPage() {
|
|||
const [password, setPassword] = useState('')
|
||||
const [isValid, setIsValid] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const { data: session } = useSession()
|
||||
const router = useRouter()
|
||||
|
@ -26,6 +27,7 @@ export default function LoginPage() {
|
|||
e.preventDefault()
|
||||
setErrorMessage(null)
|
||||
setPassword('')
|
||||
setIsLoading(true)
|
||||
|
||||
const response = await signIn('credentials', {
|
||||
email,
|
||||
|
@ -35,9 +37,17 @@ export default function LoginPage() {
|
|||
|
||||
if (response && response.error) {
|
||||
setErrorMessage(response.error)
|
||||
setIsLoading(false)
|
||||
setIsValid(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onPasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setErrorMessage(null)
|
||||
setPassword(e.target.value)
|
||||
setIsValid(e.target.value.length > 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
|
@ -57,6 +67,7 @@ export default function LoginPage() {
|
|||
<form className="space-y-4 w-full px-4" onSubmit={onSubmit}>
|
||||
<Input
|
||||
type="text"
|
||||
name="email"
|
||||
label="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
|
@ -64,17 +75,11 @@ export default function LoginPage() {
|
|||
|
||||
<InputPassword
|
||||
autoComplete="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
showPasswordRequirements={!isValid}
|
||||
onValidityChange={(checks) => {
|
||||
const passwordValid =
|
||||
checks.filter((c) => !c.isValid).length === 0
|
||||
setIsValid(passwordValid)
|
||||
}}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setPassword(e.target.value)
|
||||
}
|
||||
onChange={onPasswordChange}
|
||||
showComplexityBar={false}
|
||||
/>
|
||||
|
||||
{errorMessage && password.length === 0 ? (
|
||||
|
@ -85,8 +90,9 @@ export default function LoginPage() {
|
|||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
disabled={!isValid || isLoading}
|
||||
variant={isValid ? 'primary' : 'secondary'}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Log in
|
||||
</Button>
|
||||
|
|
|
@ -14,6 +14,7 @@ export default function RegisterPage() {
|
|||
const [password, setPassword] = useState('')
|
||||
const [isValid, setIsValid] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const { data: session } = useSession()
|
||||
const router = useRouter()
|
||||
|
@ -30,6 +31,7 @@ export default function RegisterPage() {
|
|||
setLastName('')
|
||||
setEmail('')
|
||||
setPassword('')
|
||||
setIsLoading(true)
|
||||
|
||||
const response = await signIn('credentials', {
|
||||
email,
|
||||
|
@ -41,6 +43,7 @@ export default function RegisterPage() {
|
|||
|
||||
if (response && response.error) {
|
||||
setErrorMessage(response.error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -63,18 +66,21 @@ export default function RegisterPage() {
|
|||
<form className="space-y-4 w-full px-4" onSubmit={onSubmit}>
|
||||
<Input
|
||||
type="text"
|
||||
name="firstName"
|
||||
label="First name"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.currentTarget.value)}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
name="lastName"
|
||||
label="Last name"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.currentTarget.value)}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
name="email"
|
||||
label="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
|
@ -82,6 +88,7 @@ export default function RegisterPage() {
|
|||
|
||||
<InputPassword
|
||||
autoComplete="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
showPasswordRequirements={!isValid}
|
||||
|
@ -105,6 +112,7 @@ export default function RegisterPage() {
|
|||
type="submit"
|
||||
disabled={!isValid}
|
||||
variant={isValid ? 'primary' : 'secondary'}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
|
|
|
@ -35,7 +35,7 @@ export default function SettingsPage() {
|
|||
<Tab.List>
|
||||
<Tab>Details</Tab>
|
||||
<Tab>Security</Tab>
|
||||
<Tab>Billing</Tab>
|
||||
{process.env.STRIPE_API_KEY && <Tab>Billing</Tab>}
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
|
@ -46,9 +46,11 @@ export default function SettingsPage() {
|
|||
<SecurityPreferences />
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<BillingPreferences />
|
||||
</Tab.Panel>
|
||||
{process.env.STRIPE_API_KEY && (
|
||||
<Tab.Panel>
|
||||
<BillingPreferences />
|
||||
</Tab.Panel>
|
||||
)}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</section>
|
||||
|
|
|
@ -75,9 +75,5 @@ module.exports = merge(designSystemConfig, {
|
|||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/line-clamp'),
|
||||
require('@tailwindcss/forms'),
|
||||
require('@tailwindcss/typography'),
|
||||
],
|
||||
plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
|
||||
})
|
||||
|
|
|
@ -11,14 +11,10 @@ export default defineConfig({
|
|||
baseUrl: 'http://localhost:4200',
|
||||
env: {
|
||||
API_URL: 'http://localhost:3333/v1',
|
||||
AUTH0_ID: 'REPLACE_THIS',
|
||||
AUTH0_CLIENT_ID: 'REPLACE_THIS',
|
||||
AUTH0_NAME: 'Engineering CI',
|
||||
AUTH0_EMAIL: 'REPLACE_THIS',
|
||||
AUTH0_PASSWORD: 'REPLACE_THIS',
|
||||
AUTH0_DOMAIN: 'REPLACE_THIS',
|
||||
STRIPE_WEBHOOK_SECRET:
|
||||
'REPLACE_THIS',
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
NEXT_PUBLIC_NEXTAUTH_URL: 'http://localhost:4200',
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
STRIPE_WEBHOOK_SECRET: 'REPLACE_THIS',
|
||||
STRIPE_CUSTOMER_ID: 'REPLACE_THIS',
|
||||
STRIPE_SUBSCRIPTION_ID: 'REPLACE_THIS',
|
||||
},
|
||||
|
|
|
@ -19,81 +19,6 @@ function openEditAccountModal() {
|
|||
}
|
||||
|
||||
describe('Accounts', () => {
|
||||
it('should sync and edit a plaid connection', () => {
|
||||
cy.apiRequest({
|
||||
method: 'POST',
|
||||
url: 'e2e/plaid/connect',
|
||||
}).then((response) => {
|
||||
expect(response.status).to.eql(200)
|
||||
|
||||
// The only time we need to manually send a Plaid webhook is in Github actions when testing a PR
|
||||
if (Cypress.env('WEBHOOK_TYPE') === 'mock') {
|
||||
const { plaidItemId } = response.body.data.json
|
||||
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: `${Cypress.env('API_URL')}/plaid/webhook`,
|
||||
body: {
|
||||
webhook_type: 'TRANSACTIONS',
|
||||
webhook_code: 'HISTORICAL_UPDATE',
|
||||
item_id: plaidItemId,
|
||||
},
|
||||
})
|
||||
.its('status')
|
||||
.should('equal', 200)
|
||||
}
|
||||
})
|
||||
|
||||
// Check account sidebar names and balances
|
||||
cy.visit('/accounts')
|
||||
cy.getByTestId('account-group', { timeout: 20_000 })
|
||||
|
||||
assertSidebarAccounts([
|
||||
['Assets', '$20,000'],
|
||||
['Cash', '$20,000'],
|
||||
['Sandbox Savings', '$15,000'],
|
||||
['Sandbox Checking', '$5,000'],
|
||||
['Debts', '$950'],
|
||||
['Credit Cards', '$950'],
|
||||
['Sandbox CC', '$950'],
|
||||
])
|
||||
|
||||
// Check current net worth
|
||||
cy.visit('/')
|
||||
cy.getByTestId('current-data-value').should('contain.text', '$19,050.00')
|
||||
|
||||
// Visit each account page, edit details, re-validate amounts
|
||||
cy.contains('a', 'Sandbox Checking').click()
|
||||
cy.getByTestId('current-data-value').should('contain.text', '$5,000.00')
|
||||
|
||||
cy.contains('a', 'Sandbox Savings').click()
|
||||
cy.getByTestId('current-data-value').should('contain.text', '$15,000.00')
|
||||
|
||||
cy.contains('a', 'Sandbox CC').click()
|
||||
cy.getByTestId('current-data-value').should('contain.text', '$950.00')
|
||||
|
||||
openEditAccountModal()
|
||||
|
||||
cy.getByTestId('connected-account-form').within(() => {
|
||||
cy.get('input[name="name"]')
|
||||
.should('have.value', 'Sandbox CC')
|
||||
.clear()
|
||||
.type('Credit Credit')
|
||||
cy.get('input[name="categoryUser"]').should('have.value', 'credit')
|
||||
cy.get('input[name="startDate"]')
|
||||
.should('have.value', '')
|
||||
.type(DateTime.now().minus({ months: 1 }).toFormat('MMddyyyy'))
|
||||
cy.root().submit()
|
||||
})
|
||||
|
||||
// Should be able to submit empty start date on connected account
|
||||
openEditAccountModal()
|
||||
cy.getByTestId('connected-account-form').within(() => {
|
||||
cy.get('input[name="startDate"]').clear()
|
||||
cy.root().submit()
|
||||
})
|
||||
})
|
||||
|
||||
it('should interpolate and display manual vehicle account data', () => {
|
||||
cy.getByTestId('add-account-button').click()
|
||||
cy.contains('h4', 'Add account')
|
||||
|
@ -149,7 +74,8 @@ describe('Accounts', () => {
|
|||
cy.contains('h4', 'Add real estate')
|
||||
|
||||
// Details
|
||||
cy.get('input[name="country"]').select('GB')
|
||||
cy.contains('label', 'Country').click()
|
||||
cy.contains('button', 'Uganda').click()
|
||||
cy.get('input[name="line1"]').focus().type('123 Example St')
|
||||
cy.get('input[name="city"]').type('New York')
|
||||
cy.get('input[name="state"]').type('NY')
|
||||
|
@ -188,7 +114,9 @@ describe('Accounts', () => {
|
|||
openEditAccountModal()
|
||||
|
||||
cy.getByTestId('property-form').within(() => {
|
||||
cy.get('input[name="country]').should('have.value', 'GB').clear().select('FR')
|
||||
cy.get('input[name="country"]').should('have.value', 'UG')
|
||||
cy.contains('label', 'Country').click()
|
||||
cy.contains('button', 'United States').click()
|
||||
cy.get('input[name="line1"]')
|
||||
.should('have.value', '123 Example St')
|
||||
.clear()
|
||||
|
|
9
apps/e2e/src/e2e/auth.cy.ts
Normal file
9
apps/e2e/src/e2e/auth.cy.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
describe('Auth', () => {
|
||||
beforeEach(() => cy.visit('/'))
|
||||
|
||||
describe('Logging in', () => {
|
||||
it('should show the home page of an authenticated user', () => {
|
||||
cy.contains('h5', 'Assets & Debts')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,17 +1,13 @@
|
|||
import jwtDecode from 'jwt-decode'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Cypress {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface Chainable<Subject> {
|
||||
login(username: string, password: string): Chainable<any>
|
||||
apiRequest(...params: Parameters<typeof cy.request>): Chainable<any>
|
||||
getByTestId(...parameters: Parameters<typeof cy.get>): Chainable<any>
|
||||
selectDate(date: Date): Chainable<any>
|
||||
preserveAccessToken(): Chainable<any>
|
||||
restoreAccessToken(): Chainable<any>
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
declare namespace Cypress {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface Chainable<Subject> {
|
||||
login(): Chainable<any>
|
||||
apiRequest(...params: Parameters<typeof cy.request>): Chainable<any>
|
||||
getByTestId(...parameters: Parameters<typeof cy.get>): Chainable<any>
|
||||
selectDate(date: Date): Chainable<any>
|
||||
preserveAccessToken(): Chainable<any>
|
||||
restoreAccessToken(): Chainable<any>
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -20,13 +16,10 @@ Cypress.Commands.add('getByTestId', (testId, ...rest) => {
|
|||
})
|
||||
|
||||
Cypress.Commands.add('apiRequest', ({ url, headers = {}, ...options }, ...rest) => {
|
||||
const accessToken = window.localStorage.getItem('token')
|
||||
|
||||
return cy.request(
|
||||
{
|
||||
url: `${Cypress.env('API_URL')}/${url}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...headers,
|
||||
},
|
||||
...options,
|
||||
|
@ -35,74 +28,11 @@ Cypress.Commands.add('apiRequest', ({ url, headers = {}, ...options }, ...rest)
|
|||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Logs in with the engineering CI account
|
||||
*/
|
||||
Cypress.Commands.add('login', (username, password) => {
|
||||
// Preserves login across tests
|
||||
cy.session('login-session-key', login, {
|
||||
validate() {
|
||||
cy.apiRequest({ url: 'e2e' }).its('status').should('eq', 200)
|
||||
},
|
||||
})
|
||||
|
||||
function login() {
|
||||
const client_id = Cypress.env('AUTH0_CLIENT_ID')
|
||||
const audience = 'https://maybe-finance-api/v1'
|
||||
const scope = 'openid profile email offline_access'
|
||||
const accessTokenStorageKey = `@@auth0spajs@@::${client_id}::${audience}::${scope}`
|
||||
const AUTH_DOMAIN = Cypress.env('AUTH0_DOMAIN')
|
||||
|
||||
cy.log(`Logging in as ${username}`)
|
||||
|
||||
/**
|
||||
* Uses the official Cypress Auth0 strategy for testing with Auth0
|
||||
* https://docs.cypress.io/guides/testing-strategies/auth0-authentication#Auth0-Application-Setup
|
||||
*
|
||||
* Relevant Auth0 endpoint
|
||||
* https://auth0.com/docs/api/authentication?javascript#resource-owner-password
|
||||
*/
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: `https://${AUTH_DOMAIN}/oauth/token`,
|
||||
body: {
|
||||
grant_type: 'password',
|
||||
username,
|
||||
password,
|
||||
audience,
|
||||
scope,
|
||||
client_id,
|
||||
},
|
||||
}).then(({ body }) => {
|
||||
const claims = jwtDecode<any>(body.id_token)
|
||||
|
||||
const { nickname, name, picture, updated_at, email, email_verified, sub, exp } = claims
|
||||
|
||||
const item = {
|
||||
body: {
|
||||
...body,
|
||||
decodedToken: {
|
||||
claims,
|
||||
user: {
|
||||
nickname,
|
||||
name,
|
||||
picture,
|
||||
updated_at,
|
||||
email,
|
||||
email_verified,
|
||||
sub,
|
||||
},
|
||||
audience,
|
||||
client_id,
|
||||
},
|
||||
},
|
||||
expiresAt: exp,
|
||||
}
|
||||
|
||||
window.localStorage.setItem(accessTokenStorageKey, JSON.stringify(item))
|
||||
window.localStorage.setItem('token', body.access_token)
|
||||
|
||||
cy.visit('/')
|
||||
})
|
||||
}
|
||||
Cypress.Commands.add('login', () => {
|
||||
cy.visit('/login')
|
||||
cy.get('input[name="email"]').type('bond@007.com')
|
||||
cy.get('input[name="password"]').type('TestPassword123')
|
||||
cy.get('button[type="submit"]').click()
|
||||
//eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(1000)
|
||||
})
|
||||
|
|
|
@ -2,8 +2,7 @@ import './commands'
|
|||
|
||||
beforeEach(() => {
|
||||
// Login
|
||||
// Rate limit 30 / min - https://auth0.com/docs/troubleshoot/customer-support/operational-policies/rate-limit-policy#limits-for-non-production-tenants-of-paying-customers-and-all-tenants-of-free-customers
|
||||
cy.login(Cypress.env('AUTH0_EMAIL'), Cypress.env('AUTH0_PASSWORD'))
|
||||
cy.login()
|
||||
|
||||
// Delete the current user to wipe all data before test
|
||||
cy.apiRequest({
|
||||
|
@ -14,6 +13,6 @@ beforeEach(() => {
|
|||
expect(response.status).to.equal(200)
|
||||
})
|
||||
|
||||
// Re-login (JWT should still be valid)
|
||||
// Go back to dashboard
|
||||
cy.visit('/')
|
||||
})
|
||||
|
|
2
apps/e2e/src/support/index.ts
Normal file
2
apps/e2e/src/support/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
import './commands'
|
||||
import './e2e'
|
|
@ -35,7 +35,6 @@ import {
|
|||
accountRollupRouter,
|
||||
valuationsRouter,
|
||||
institutionsRouter,
|
||||
finicityRouter,
|
||||
tellerRouter,
|
||||
transactionsRouter,
|
||||
holdingsRouter,
|
||||
|
@ -110,7 +109,6 @@ app.use(
|
|||
app.use('/v1/stripe', express.raw({ type: 'application/json' }))
|
||||
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
app.use(express.json({ limit: '50mb' })) // Finicity sends large response bodies for webhooks
|
||||
|
||||
// =========================================
|
||||
// API ⬇️
|
||||
|
@ -158,7 +156,6 @@ app.use('/v1', validateAuthJwt)
|
|||
app.use('/v1/users', usersRouter)
|
||||
app.use('/v1/e2e', e2eRouter)
|
||||
app.use('/v1/plaid', plaidRouter)
|
||||
app.use('/v1/finicity', finicityRouter)
|
||||
app.use('/v1/teller', tellerRouter)
|
||||
app.use('/v1/accounts', accountsRouter)
|
||||
app.use('/v1/account-rollup', accountRollupRouter)
|
||||
|
|
|
@ -37,10 +37,7 @@ import {
|
|||
TransactionBalanceSyncStrategy,
|
||||
InvestmentTransactionBalanceSyncStrategy,
|
||||
PlaidETL,
|
||||
FinicityService,
|
||||
FinicityETL,
|
||||
InstitutionProviderFactory,
|
||||
FinicityWebhookHandler,
|
||||
PlaidWebhookHandler,
|
||||
TellerService,
|
||||
TellerETL,
|
||||
|
@ -56,7 +53,6 @@ import {
|
|||
} from '@maybe-finance/server/features'
|
||||
import prisma from './prisma'
|
||||
import plaid, { getPlaidWebhookUrl } from './plaid'
|
||||
import finicity, { getFinicityTxPushUrl, getFinicityWebhookUrl } from './finicity'
|
||||
import teller, { getTellerWebhookUrl } from './teller'
|
||||
import stripe from './stripe'
|
||||
import postmark from './postmark'
|
||||
|
@ -136,15 +132,6 @@ const plaidService = new PlaidService(
|
|||
env.NX_CLIENT_URL_CUSTOM || env.NX_CLIENT_URL
|
||||
)
|
||||
|
||||
const finicityService = new FinicityService(
|
||||
logger.child({ service: 'FinicityService' }),
|
||||
prisma,
|
||||
finicity,
|
||||
new FinicityETL(logger.child({ service: 'FinicityETL' }), prisma, finicity),
|
||||
getFinicityWebhookUrl(),
|
||||
env.NX_FINICITY_ENV === 'sandbox'
|
||||
)
|
||||
|
||||
const tellerService = new TellerService(
|
||||
logger.child({ service: 'TellerService' }),
|
||||
prisma,
|
||||
|
@ -159,7 +146,6 @@ const tellerService = new TellerService(
|
|||
|
||||
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
|
||||
plaid: plaidService,
|
||||
finicity: finicityService,
|
||||
teller: tellerService,
|
||||
})
|
||||
|
||||
|
@ -239,7 +225,6 @@ const userService = new UserService(
|
|||
|
||||
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||
PLAID: plaidService,
|
||||
FINICITY: finicityService,
|
||||
TELLER: tellerService,
|
||||
})
|
||||
|
||||
|
@ -279,14 +264,6 @@ const plaidWebhooks = new PlaidWebhookHandler(
|
|||
queueService
|
||||
)
|
||||
|
||||
const finicityWebhooks = new FinicityWebhookHandler(
|
||||
logger.child({ service: 'FinicityWebhookHandler' }),
|
||||
prisma,
|
||||
finicity,
|
||||
accountConnectionService,
|
||||
getFinicityTxPushUrl()
|
||||
)
|
||||
|
||||
const stripeWebhooks = new StripeWebhookHandler(
|
||||
logger.child({ service: 'StripeWebhookHandler' }),
|
||||
prisma,
|
||||
|
@ -358,8 +335,6 @@ export async function createContext(req: Request) {
|
|||
queueService,
|
||||
plaidService,
|
||||
plaidWebhooks,
|
||||
finicityService,
|
||||
finicityWebhooks,
|
||||
stripeWebhooks,
|
||||
tellerService,
|
||||
tellerWebhooks,
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
import { FinicityApi } from '@maybe-finance/finicity-api'
|
||||
import { getWebhookUrl } from './webhook'
|
||||
import env from '../../env'
|
||||
|
||||
const finicity = new FinicityApi(
|
||||
env.NX_FINICITY_APP_KEY,
|
||||
env.NX_FINICITY_PARTNER_ID,
|
||||
env.NX_FINICITY_PARTNER_SECRET
|
||||
)
|
||||
|
||||
export default finicity
|
||||
|
||||
export async function getFinicityWebhookUrl() {
|
||||
const webhookUrl = await getWebhookUrl()
|
||||
return `${webhookUrl}/v1/finicity/webhook`
|
||||
}
|
||||
|
||||
export async function getFinicityTxPushUrl() {
|
||||
const webhookUrl = await getWebhookUrl()
|
||||
return `${webhookUrl}/v1/finicity/txpush`
|
||||
}
|
|
@ -4,7 +4,6 @@ export * from './auth-error-handler'
|
|||
export * from './superjson'
|
||||
export * from './validate-auth-jwt'
|
||||
export * from './validate-plaid-jwt'
|
||||
export * from './validate-finicity-signature'
|
||||
export * from './validate-teller-signature'
|
||||
export { default as maintenance } from './maintenance'
|
||||
export * from './identify-user'
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
import type { RequestHandler } from 'express'
|
||||
import crypto from 'crypto'
|
||||
import env from '../../env'
|
||||
|
||||
/**
|
||||
* middleware to validate the `x-finicity-signature` header
|
||||
*
|
||||
* https://docs.finicity.com/connect-and-mvs-webhooks/
|
||||
*/
|
||||
export const validateFinicitySignature: RequestHandler = (req, res, next) => {
|
||||
const signature = crypto
|
||||
.createHmac('sha256', env.NX_FINICITY_PARTNER_SECRET)
|
||||
.update(JSON.stringify(req.body))
|
||||
.digest('hex')
|
||||
|
||||
if (req.get('x-finicity-signature') !== signature) {
|
||||
throw new Error('invalid finicity signature')
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
|
@ -99,20 +99,6 @@ router.post(
|
|||
})
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:id/finicity/fix-connect',
|
||||
endpoint.create({
|
||||
resolve: async ({ ctx, req }) => {
|
||||
const { link } = await ctx.finicityService.generateFixConnectUrl(
|
||||
ctx.user!.id,
|
||||
+req.params.id
|
||||
)
|
||||
|
||||
return { link }
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:id/sync',
|
||||
endpoint.create({
|
||||
|
|
|
@ -6,13 +6,13 @@ import endpoint from '../lib/endpoint'
|
|||
|
||||
const router = Router()
|
||||
|
||||
router.use((req, res, next) => {
|
||||
const roles = req.user?.['https://maybe.co/roles']
|
||||
const testUserId = 'test_ec3ee8a4-fa01-4f11-8ac5-9c49dd7fbae4'
|
||||
|
||||
if (roles?.includes('CIUser') || roles?.includes('Admin')) {
|
||||
router.use((req, res, next) => {
|
||||
if (req.user?.sub === testUserId) {
|
||||
next()
|
||||
} else {
|
||||
res.status(401).send('Route only available to CIUser and Admin roles')
|
||||
res.status(401).send('Route only available to test users')
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -47,14 +47,14 @@ router.post(
|
|||
trialLapsed: z.boolean().default(false),
|
||||
}),
|
||||
resolve: async ({ ctx, input }) => {
|
||||
ctx.logger.debug(`Resetting CI user ${ctx.user!.authId}`)
|
||||
|
||||
await ctx.prisma.$transaction([
|
||||
ctx.prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${ctx.user!.authId};`,
|
||||
ctx.prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${testUserId};`,
|
||||
ctx.prisma.user.create({
|
||||
data: {
|
||||
authId: ctx.user!.authId,
|
||||
email: 'REPLACE_THIS',
|
||||
authId: testUserId,
|
||||
email: 'bond@007.com',
|
||||
firstName: 'James',
|
||||
lastName: 'Bond',
|
||||
dob: new Date('1990-01-01'),
|
||||
linkAccountDismissedAt: new Date(), // ensures our auto-account link doesn't trigger
|
||||
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
import { z } from 'zod'
|
||||
import { Router } from 'express'
|
||||
import endpoint from '../lib/endpoint'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post(
|
||||
'/connect-url',
|
||||
endpoint.create({
|
||||
input: z.object({
|
||||
institutionId: z.string(),
|
||||
}),
|
||||
resolve: async ({ ctx, input }) => {
|
||||
return await ctx.finicityService.generateConnectUrl(ctx.user!.id, input.institutionId)
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/institutions/sync',
|
||||
endpoint.create({
|
||||
resolve: async ({ ctx }) => {
|
||||
ctx.ability.throwUnlessCan('manage', 'Institution')
|
||||
await ctx.queueService
|
||||
.getQueue('sync-institution')
|
||||
.add('sync-finicity-institutions', {})
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
|
@ -4,7 +4,6 @@ export { default as connectionsRouter } from './connections.router'
|
|||
export { default as usersRouter } from './users.router'
|
||||
export { default as webhooksRouter } from './webhooks.router'
|
||||
export { default as plaidRouter } from './plaid.router'
|
||||
export { default as finicityRouter } from './finicity.router'
|
||||
export { default as tellerRouter } from './teller.router'
|
||||
export { default as valuationsRouter } from './valuations.router'
|
||||
export { default as institutionsRouter } from './institutions.router'
|
||||
|
|
|
@ -27,11 +27,10 @@ router.post(
|
|||
resolve: async ({ ctx }) => {
|
||||
ctx.ability.throwUnlessCan('update', 'Institution')
|
||||
|
||||
// Sync all Plaid + Finicity institutions
|
||||
await ctx.queueService.getQueue('sync-institution').addBulk([
|
||||
{ name: 'sync-plaid-institutions', data: {} },
|
||||
{ name: 'sync-finicity-institutions', data: {} },
|
||||
])
|
||||
// Sync all Plaid institutions
|
||||
await ctx.queueService
|
||||
.getQueue('sync-institution')
|
||||
.addBulk([{ name: 'sync-plaid-institutions', data: {} }])
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import type { FinicityTypes } from '@maybe-finance/finicity-api'
|
||||
import { validatePlaidJwt, validateFinicitySignature, validateTellerSignature } from '../middleware'
|
||||
import { validatePlaidJwt, validateTellerSignature } from '../middleware'
|
||||
import endpoint from '../lib/endpoint'
|
||||
import stripe from '../lib/stripe'
|
||||
import env from '../../env'
|
||||
|
@ -35,74 +34,6 @@ router.post(
|
|||
})
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finicity/webhook',
|
||||
process.env.NODE_ENV !== 'development'
|
||||
? validateFinicitySignature
|
||||
: (_req, _res, next) => next(),
|
||||
endpoint.create({
|
||||
input: z
|
||||
.object({
|
||||
eventType: z.string(),
|
||||
eventId: z.string().optional(),
|
||||
customerId: z.string().optional(),
|
||||
payload: z.record(z.any()).optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
async resolve({ input, ctx }) {
|
||||
const { eventType, eventId, customerId } = input
|
||||
|
||||
ctx.logger.info(
|
||||
`rx[finicity_webhook] event eventType=${eventType} eventId=${eventId} customerId=${customerId}`
|
||||
)
|
||||
|
||||
// May contain sensitive info, only print at the debug level
|
||||
ctx.logger.debug(`rx[finicity_webhook] event payload`, input)
|
||||
|
||||
try {
|
||||
await ctx.finicityWebhooks.handleWebhook(input as FinicityTypes.WebhookData)
|
||||
} catch (err) {
|
||||
// record error but don't throw, otherwise Finicity Connect behaves weird
|
||||
ctx.logger.error(`[finicity_webhook] error handling webhook`, err)
|
||||
}
|
||||
|
||||
return { status: 'ok' }
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
router.get('/finicity/txpush', (req, res) => {
|
||||
const { txpush_verification_code } = req.query
|
||||
if (!txpush_verification_code) {
|
||||
return res.status(400).send('request missing txpush_verification_code')
|
||||
}
|
||||
|
||||
return res.status(200).contentType('text/plain').send(txpush_verification_code)
|
||||
})
|
||||
|
||||
router.post(
|
||||
'/finicity/txpush',
|
||||
endpoint.create({
|
||||
input: z
|
||||
.object({
|
||||
event: z.record(z.any()), // for now we'll just cast this to the appropriate type
|
||||
})
|
||||
.passthrough(),
|
||||
async resolve({ input: { event }, ctx }) {
|
||||
const ev = event as FinicityTypes.TxPushEvent
|
||||
|
||||
ctx.logger.info(`rx[finicity_txpush] event class=${ev.class} type=${ev.type}`)
|
||||
|
||||
// May contain sensitive info, only print at the debug level
|
||||
ctx.logger.debug(`rx[finicity_txpush] event payload`, event)
|
||||
|
||||
await ctx.finicityWebhooks.handleTxPushEvent(ev)
|
||||
|
||||
return { status: 'ok' }
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/stripe/webhook',
|
||||
endpoint.create({
|
||||
|
|
|
@ -36,11 +36,6 @@ const envSchema = z.object({
|
|||
NX_PLAID_SECRET: z.string(),
|
||||
NX_PLAID_ENV: z.string().default('sandbox'),
|
||||
|
||||
NX_FINICITY_APP_KEY: z.string(),
|
||||
NX_FINICITY_PARTNER_ID: z.string().default('REPLACE_THIS'),
|
||||
NX_FINICITY_PARTNER_SECRET: z.string(),
|
||||
NX_FINICITY_ENV: z.string().default('sandbox'),
|
||||
|
||||
NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'),
|
||||
NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'),
|
||||
NX_TELLER_ENV: z.string().default('sandbox'),
|
||||
|
|
|
@ -1,317 +0,0 @@
|
|||
import fs from 'fs'
|
||||
import type { User } from '@prisma/client'
|
||||
import { FinicityTestData } from '../../../../../tools/test-data'
|
||||
import { FinicityApi, type FinicityTypes } from '@maybe-finance/finicity-api'
|
||||
jest.mock('@maybe-finance/finicity-api')
|
||||
import {
|
||||
FinicityETL,
|
||||
FinicityService,
|
||||
type IAccountConnectionProvider,
|
||||
} from '@maybe-finance/server/features'
|
||||
import { createLogger, etl } from '@maybe-finance/server/shared'
|
||||
import prisma from '../lib/prisma'
|
||||
import { resetUser } from './helpers/user.test-helper'
|
||||
import { transports } from 'winston'
|
||||
|
||||
const logger = createLogger({ level: 'debug', transports: [new transports.Console()] })
|
||||
const finicity = jest.mocked(new FinicityApi('APP_KEY', 'PARTNER_ID', 'PARTNER_SECRET'))
|
||||
|
||||
/** mock implementation of finicity's pagination logic which as of writing uses 1-based indexing */
|
||||
function finicityPaginate<T>(data: T[], start = 1, limit = 1_000): T[] {
|
||||
const startIdx = Math.max(0, start - 1)
|
||||
return data.slice(startIdx, startIdx + limit)
|
||||
}
|
||||
|
||||
describe('Finicity', () => {
|
||||
let user: User
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
user = await resetUser(prisma)
|
||||
})
|
||||
|
||||
it('syncs connection', async () => {
|
||||
finicity.getCustomerAccounts.mockResolvedValue({ accounts: FinicityTestData.accounts })
|
||||
|
||||
finicity.getAccountTransactions.mockImplementation(({ accountId, start, limit }) => {
|
||||
const transactions = FinicityTestData.transactions.filter(
|
||||
(t) => t.accountId === +accountId
|
||||
)
|
||||
|
||||
const page = finicityPaginate(transactions, start, limit)
|
||||
|
||||
return Promise.resolve({
|
||||
transactions: page,
|
||||
found: transactions.length,
|
||||
displaying: page.length,
|
||||
moreAvailable: page.length < transactions.length ? 'true' : 'false',
|
||||
fromDate: '1588939200',
|
||||
toDate: '1651492800',
|
||||
sort: 'desc',
|
||||
})
|
||||
})
|
||||
|
||||
const finicityETL = new FinicityETL(logger, prisma, finicity)
|
||||
|
||||
const service: IAccountConnectionProvider = new FinicityService(
|
||||
logger,
|
||||
prisma,
|
||||
finicity,
|
||||
finicityETL,
|
||||
'',
|
||||
true
|
||||
)
|
||||
|
||||
const connection = await prisma.accountConnection.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: 'TEST_FINICITY',
|
||||
type: 'finicity',
|
||||
finicityInstitutionId: '101732',
|
||||
finicityInstitutionLoginId: '6000483842',
|
||||
},
|
||||
})
|
||||
|
||||
await service.sync(connection)
|
||||
|
||||
const { accounts } = await prisma.accountConnection.findUniqueOrThrow({
|
||||
where: {
|
||||
id: connection.id,
|
||||
},
|
||||
include: {
|
||||
accounts: {
|
||||
include: {
|
||||
transactions: true,
|
||||
investmentTransactions: true,
|
||||
holdings: true,
|
||||
valuations: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(accounts).toHaveLength(FinicityTestData.accounts.length)
|
||||
|
||||
// eslint-disable-next-line
|
||||
const [auto, mortgage, roth, brokerage, loc, credit, savings, checking] =
|
||||
FinicityTestData.accounts
|
||||
|
||||
// mortgage
|
||||
const mortgageAccount = accounts.find((a) => a.finicityAccountId === mortgage.id)!
|
||||
expect(mortgageAccount.transactions).toHaveLength(
|
||||
FinicityTestData.transactions.filter((t) => t.accountId === +mortgage.id).length
|
||||
)
|
||||
expect(mortgageAccount.holdings).toHaveLength(0)
|
||||
expect(mortgageAccount.valuations).toHaveLength(0)
|
||||
expect(mortgageAccount.investmentTransactions).toHaveLength(0)
|
||||
|
||||
// brokerage
|
||||
const brokerageAccount = accounts.find((a) => a.finicityAccountId === brokerage.id)!
|
||||
expect(brokerageAccount.transactions).toHaveLength(0)
|
||||
expect(brokerageAccount.holdings).toHaveLength(brokerage.position!.length)
|
||||
expect(brokerageAccount.valuations).toHaveLength(0)
|
||||
expect(brokerageAccount.investmentTransactions).toHaveLength(
|
||||
FinicityTestData.transactions.filter((t) => t.accountId === +brokerage.id).length
|
||||
)
|
||||
|
||||
// credit
|
||||
const creditAccount = accounts.find((a) => a.finicityAccountId === credit.id)!
|
||||
expect(creditAccount.transactions).toHaveLength(
|
||||
FinicityTestData.transactions.filter((t) => t.accountId === +credit.id).length
|
||||
)
|
||||
expect(creditAccount.holdings).toHaveLength(0)
|
||||
expect(creditAccount.valuations).toHaveLength(0)
|
||||
expect(creditAccount.investmentTransactions).toHaveLength(0)
|
||||
|
||||
// savings
|
||||
const savingsAccount = accounts.find((a) => a.finicityAccountId === savings.id)!
|
||||
expect(savingsAccount.transactions).toHaveLength(
|
||||
FinicityTestData.transactions.filter((t) => t.accountId === +savings.id).length
|
||||
)
|
||||
expect(savingsAccount.holdings).toHaveLength(0)
|
||||
expect(savingsAccount.valuations).toHaveLength(0)
|
||||
expect(savingsAccount.investmentTransactions).toHaveLength(0)
|
||||
|
||||
// checking
|
||||
const checkingAccount = accounts.find((a) => a.finicityAccountId === checking.id)!
|
||||
expect(checkingAccount.transactions).toHaveLength(
|
||||
FinicityTestData.transactions.filter((t) => t.accountId === +checking.id).length
|
||||
)
|
||||
expect(checkingAccount.holdings).toHaveLength(0)
|
||||
expect(checkingAccount.valuations).toHaveLength(0)
|
||||
expect(checkingAccount.investmentTransactions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('syncs Betterment investment account', async () => {
|
||||
finicity.getCustomerAccounts.mockResolvedValue({
|
||||
accounts: [FinicityTestData.bettermentAccount],
|
||||
})
|
||||
|
||||
finicity.getAccountTransactions.mockImplementation(({ accountId, start, limit }) => {
|
||||
const transactions = FinicityTestData.bettermentTransactions.filter(
|
||||
(t) => t.accountId === +accountId
|
||||
)
|
||||
|
||||
const page = finicityPaginate(transactions, start, limit)
|
||||
|
||||
return Promise.resolve({
|
||||
transactions: page,
|
||||
found: transactions.length,
|
||||
displaying: page.length,
|
||||
moreAvailable: page.length < transactions.length ? 'true' : 'false',
|
||||
fromDate: '1588939200',
|
||||
toDate: '1651492800',
|
||||
sort: 'desc',
|
||||
})
|
||||
})
|
||||
|
||||
const finicityETL = new FinicityETL(logger, prisma, finicity)
|
||||
|
||||
const connection = await prisma.accountConnection.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: 'TEST[Betterment]',
|
||||
type: 'finicity',
|
||||
finicityInstitutionId: FinicityTestData.bettermentAccount.institutionId,
|
||||
finicityInstitutionLoginId:
|
||||
FinicityTestData.bettermentAccount.institutionLoginId.toString(),
|
||||
},
|
||||
})
|
||||
|
||||
await etl(finicityETL, connection)
|
||||
})
|
||||
|
||||
it('syncs investment transactions w/o securities', async () => {
|
||||
finicity.getCustomerAccounts.mockResolvedValue({
|
||||
accounts: [FinicityTestData.accounts.find((a) => a.type === 'investment')!],
|
||||
})
|
||||
|
||||
finicity.getAccountTransactions.mockImplementation(({ accountId, start, limit }) => {
|
||||
const transactions: FinicityTypes.Transaction[] = [
|
||||
{
|
||||
id: 1,
|
||||
amount: 123,
|
||||
accountId: +accountId,
|
||||
customerId: 123,
|
||||
status: 'active',
|
||||
description: 'VANGUARD INST INDEX',
|
||||
memo: 'Contributions',
|
||||
type: 'Contributions',
|
||||
unitQuantity: 8.283,
|
||||
postedDate: 1674043200,
|
||||
transactionDate: 1674043200,
|
||||
createdDate: 1674707388,
|
||||
tradeDate: 1674025200,
|
||||
settlementDate: 1674043200,
|
||||
investmentTransactionType: 'contribution',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
amount: -3.21,
|
||||
accountId: +accountId,
|
||||
customerId: 123,
|
||||
status: 'active',
|
||||
description: 'VANGUARD TARGET 2045',
|
||||
memo: 'RECORDKEEPING FEE',
|
||||
type: 'RECORDKEEPING FEE',
|
||||
unitQuantity: 0.014,
|
||||
postedDate: 1672747200,
|
||||
transactionDate: 1672747200,
|
||||
createdDate: 1674707388,
|
||||
tradeDate: 1672729200,
|
||||
settlementDate: 1672747200,
|
||||
investmentTransactionType: 'fee',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
amount: -1.23,
|
||||
accountId: +accountId,
|
||||
customerId: 123,
|
||||
status: 'active',
|
||||
description: 'VANGUARD INST INDEX',
|
||||
memo: 'Realized Gain/Loss',
|
||||
type: 'Realized Gain/Loss',
|
||||
unitQuantity: 0e-8,
|
||||
postedDate: 1672747200,
|
||||
transactionDate: 1672747200,
|
||||
createdDate: 1674707388,
|
||||
tradeDate: 1672729200,
|
||||
settlementDate: 1672747200,
|
||||
investmentTransactionType: 'other',
|
||||
},
|
||||
]
|
||||
|
||||
const page = finicityPaginate(transactions, start, limit)
|
||||
|
||||
return Promise.resolve({
|
||||
transactions: page,
|
||||
found: transactions.length,
|
||||
displaying: page.length,
|
||||
moreAvailable: page.length < transactions.length ? 'true' : 'false',
|
||||
fromDate: '1588939200',
|
||||
toDate: '1651492800',
|
||||
sort: 'desc',
|
||||
})
|
||||
})
|
||||
|
||||
const finicityETL = new FinicityETL(logger, prisma, finicity)
|
||||
|
||||
const connection = await prisma.accountConnection.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: 'TEST[Betterment]',
|
||||
type: 'finicity',
|
||||
finicityInstitutionId: FinicityTestData.bettermentAccount.institutionId,
|
||||
finicityInstitutionLoginId:
|
||||
FinicityTestData.bettermentAccount.institutionLoginId.toString(),
|
||||
},
|
||||
})
|
||||
|
||||
await etl(finicityETL, connection)
|
||||
|
||||
const accounts = await prisma.account.findMany({
|
||||
where: {
|
||||
accountConnectionId: connection.id,
|
||||
},
|
||||
include: {
|
||||
holdings: true,
|
||||
transactions: true,
|
||||
investmentTransactions: true,
|
||||
},
|
||||
})
|
||||
expect(accounts).toHaveLength(1)
|
||||
|
||||
const account = accounts[0]
|
||||
expect(account.holdings).toHaveLength(0)
|
||||
expect(account.transactions).toHaveLength(0)
|
||||
expect(account.investmentTransactions).toHaveLength(3)
|
||||
})
|
||||
|
||||
/**
|
||||
* This test is for debugging w/ real data locally
|
||||
*/
|
||||
it.skip('debug', async () => {
|
||||
const data = (name: string) =>
|
||||
JSON.parse(
|
||||
fs.readFileSync(`${process.env.NX_TEST_DATA_FOLDER}/finicity/${name}.json`, 'utf-8')
|
||||
)
|
||||
|
||||
finicity.getCustomerAccounts.mockResolvedValue(data('accounts'))
|
||||
finicity.getAccountTransactions.mockResolvedValue(data('transactions'))
|
||||
|
||||
const finicityETL = new FinicityETL(logger, prisma, finicity)
|
||||
|
||||
const connection = await prisma.accountConnection.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: 'TEST[DEBUG]',
|
||||
type: 'finicity',
|
||||
finicityInstitutionId: '123',
|
||||
finicityInstitutionLoginId: '123',
|
||||
},
|
||||
})
|
||||
|
||||
await etl(finicityETL, connection)
|
||||
})
|
||||
})
|
|
@ -15,7 +15,6 @@ export async function resetUser(prisma: PrismaClient, authId = '__TEST_USER_ID__
|
|||
data: {
|
||||
authId,
|
||||
email: faker.internet.email(),
|
||||
finicityCustomerId: faker.string.uuid(),
|
||||
tellerUserId: faker.string.uuid(),
|
||||
},
|
||||
}),
|
||||
|
|
|
@ -20,8 +20,6 @@ import {
|
|||
AccountQueryService,
|
||||
AccountService,
|
||||
BalanceSyncStrategyFactory,
|
||||
FinicityETL,
|
||||
FinicityService,
|
||||
InstitutionProviderFactory,
|
||||
InstitutionService,
|
||||
InvestmentTransactionBalanceSyncStrategy,
|
||||
|
@ -56,7 +54,6 @@ import Redis from 'ioredis'
|
|||
import logger from './logger'
|
||||
import prisma from './prisma'
|
||||
import plaid from './plaid'
|
||||
import finicity from './finicity'
|
||||
import teller from './teller'
|
||||
import postmark from './postmark'
|
||||
import stripe from './stripe'
|
||||
|
@ -118,15 +115,6 @@ const plaidService = new PlaidService(
|
|||
''
|
||||
)
|
||||
|
||||
const finicityService = new FinicityService(
|
||||
logger.child({ service: 'FinicityService' }),
|
||||
prisma,
|
||||
finicity,
|
||||
new FinicityETL(logger.child({ service: 'FinicityETL' }), prisma, finicity),
|
||||
'',
|
||||
env.NX_FINICITY_ENV === 'sandbox'
|
||||
)
|
||||
|
||||
const tellerService = new TellerService(
|
||||
logger.child({ service: 'TellerService' }),
|
||||
prisma,
|
||||
|
@ -141,7 +129,6 @@ const tellerService = new TellerService(
|
|||
|
||||
const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
|
||||
plaid: plaidService,
|
||||
finicity: finicityService,
|
||||
teller: tellerService,
|
||||
})
|
||||
|
||||
|
@ -258,7 +245,6 @@ export const securityPricingProcessor: ISecurityPricingProcessor = new SecurityP
|
|||
|
||||
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||
PLAID: plaidService,
|
||||
FINICITY: finicityService,
|
||||
TELLER: tellerService,
|
||||
})
|
||||
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
import { FinicityApi } from '@maybe-finance/finicity-api'
|
||||
import env from '../../env'
|
||||
|
||||
const finicity = new FinicityApi(
|
||||
env.NX_FINICITY_APP_KEY,
|
||||
env.NX_FINICITY_PARTNER_ID,
|
||||
env.NX_FINICITY_PARTNER_SECRET
|
||||
)
|
||||
|
||||
export default finicity
|
|
@ -10,11 +10,6 @@ const envSchema = z.object({
|
|||
NX_PLAID_CLIENT_ID: z.string().default('REPLACE_THIS'),
|
||||
NX_PLAID_SECRET: z.string(),
|
||||
|
||||
NX_FINICITY_APP_KEY: z.string(),
|
||||
NX_FINICITY_PARTNER_ID: z.string().default('REPLACE_THIS'),
|
||||
NX_FINICITY_PARTNER_SECRET: z.string(),
|
||||
NX_FINICITY_ENV: z.string().default('sandbox'),
|
||||
|
||||
NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'),
|
||||
NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'),
|
||||
NX_TELLER_ENV: z.string().default('sandbox'),
|
||||
|
@ -33,6 +28,8 @@ const envSchema = z.object({
|
|||
|
||||
NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'),
|
||||
NX_CDN_PUBLIC_BUCKET: z.string().default('REPLACE_THIS'),
|
||||
|
||||
STRIPE_API_KEY: z.string().optional(),
|
||||
})
|
||||
|
||||
const env = envSchema.parse(process.env)
|
||||
|
|
|
@ -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',
|
||||
{},
|
||||
|
@ -154,11 +140,13 @@ syncInstitutionQueue.add(
|
|||
*/
|
||||
sendEmailQueue.process('send-email', async (job) => await emailProcessor.send(job.data))
|
||||
|
||||
sendEmailQueue.add(
|
||||
'send-email',
|
||||
{ type: 'trial-reminders' },
|
||||
{ repeat: { cron: '0 */12 * * *' } } // Run every 12 hours
|
||||
)
|
||||
if (env.STRIPE_API_KEY) {
|
||||
sendEmailQueue.add(
|
||||
'send-email',
|
||||
{ type: 'trial-reminders' },
|
||||
{ repeat: { cron: '0 */12 * * *' } } // Run every 12 hours
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback - usually triggered by errors not handled (or thrown) within the Bull event handlers (see above)
|
||||
process.on(
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
import {
|
||||
useAccountConnectionApi,
|
||||
useAccountContext,
|
||||
useFinicity,
|
||||
} from '@maybe-finance/client/shared'
|
||||
import { Button } from '@maybe-finance/design-system'
|
||||
|
||||
type FinicityFixConnectButtonProps = {
|
||||
accountConnectionId: number
|
||||
}
|
||||
|
||||
export default function FinicityFixConnectButton({
|
||||
accountConnectionId,
|
||||
}: FinicityFixConnectButtonProps) {
|
||||
const { launch } = useFinicity()
|
||||
const { setAccountManager } = useAccountContext()
|
||||
|
||||
const { useCreateFinicityFixConnectUrl } = useAccountConnectionApi()
|
||||
|
||||
const createFixConnectUrl = useCreateFinicityFixConnectUrl({
|
||||
onSuccess({ link }) {
|
||||
launch(link)
|
||||
setAccountManager({ view: 'idle' })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => createFixConnectUrl.mutate(accountConnectionId)}
|
||||
disabled={createFixConnectUrl.isLoading}
|
||||
>
|
||||
Fix connection
|
||||
</Button>
|
||||
)
|
||||
}
|
|
@ -6,7 +6,6 @@ import {
|
|||
useAccountContext,
|
||||
useDebounce,
|
||||
usePlaid,
|
||||
useFinicity,
|
||||
useTellerConfig,
|
||||
useTellerConnect,
|
||||
} from '@maybe-finance/client/shared'
|
||||
|
@ -39,7 +38,6 @@ export default function AccountTypeSelector({
|
|||
const config = useTellerConfig(logger)
|
||||
|
||||
const { openPlaid } = usePlaid()
|
||||
const { openFinicity } = useFinicity()
|
||||
const { open: openTeller } = useTellerConnect(config, logger)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
@ -80,9 +78,6 @@ export default function AccountTypeSelector({
|
|||
case 'PLAID':
|
||||
openPlaid(providerInstitution.providerId)
|
||||
break
|
||||
case 'FINICITY':
|
||||
openFinicity(providerInstitution.providerId)
|
||||
break
|
||||
case 'TELLER':
|
||||
openTeller(providerInstitution.providerId)
|
||||
break
|
||||
|
@ -158,9 +153,6 @@ export default function AccountTypeSelector({
|
|||
case 'PLAID':
|
||||
openPlaid(data.providerId)
|
||||
break
|
||||
case 'FINICITY':
|
||||
openFinicity(data.providerId)
|
||||
break
|
||||
case 'TELLER':
|
||||
openTeller(data.providerId)
|
||||
break
|
||||
|
|
|
@ -72,18 +72,10 @@ const brokerages: GridImage[] = [
|
|||
{
|
||||
src: 'fidelity.png',
|
||||
alt: 'Fidelity',
|
||||
institution: {
|
||||
provider: 'FINICITY',
|
||||
providerId: '9913',
|
||||
},
|
||||
},
|
||||
{
|
||||
src: 'vanguard.png',
|
||||
alt: 'Vanguard',
|
||||
institution: {
|
||||
provider: 'FINICITY',
|
||||
providerId: '3078',
|
||||
},
|
||||
},
|
||||
{
|
||||
src: 'wealthfront.png',
|
||||
|
|
|
@ -16,7 +16,6 @@ import {
|
|||
RiFolderOpenLine,
|
||||
RiMenuFoldLine,
|
||||
RiMenuUnfoldLine,
|
||||
RiMore2Fill,
|
||||
RiPieChart2Line,
|
||||
RiFlagLine,
|
||||
RiArrowRightSLine,
|
||||
|
@ -191,9 +190,11 @@ export function DesktopLayout({ children, sidebar }: DesktopLayoutProps) {
|
|||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Link href="/settings">
|
||||
<ProfileCircle />
|
||||
</Link>
|
||||
<MenuPopover
|
||||
isHeader={false}
|
||||
icon={<ProfileCircle />}
|
||||
buttonClassName="w-12 h-12 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
@ -313,7 +314,6 @@ function DefaultContent({
|
|||
email,
|
||||
}: PropsWithChildren<{ onboarding?: ReactNode; name?: string; email?: string }>) {
|
||||
const { addAccount } = useAccountContext()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
@ -338,14 +338,13 @@ function DefaultContent({
|
|||
|
||||
{onboarding && onboarding}
|
||||
|
||||
<UpgradePrompt />
|
||||
{process.env.STRIPE_API_KEY && <UpgradePrompt />}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-base">
|
||||
<p data-testid="user-name">{name ?? ''}</p>
|
||||
<p className="text-gray-100">{email ?? ''}</p>
|
||||
</div>
|
||||
<MenuPopover isHeader={false} icon={<RiMore2Fill />} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -6,19 +6,24 @@ import {
|
|||
RiShutDownLine as LogoutIcon,
|
||||
RiDatabase2Line,
|
||||
} from 'react-icons/ri'
|
||||
import classNames from 'classnames'
|
||||
|
||||
export function MenuPopover({
|
||||
icon,
|
||||
buttonClassName,
|
||||
placement = 'top-end',
|
||||
isHeader,
|
||||
}: {
|
||||
icon: JSX.Element
|
||||
buttonClassName?: string
|
||||
placement?: ComponentProps<typeof Menu.Item>['placement']
|
||||
isHeader: boolean
|
||||
}) {
|
||||
return (
|
||||
<Menu>
|
||||
<Menu.Button variant="icon">{icon}</Menu.Button>
|
||||
<Menu.Button variant="icon" className={classNames(buttonClassName)}>
|
||||
{icon}
|
||||
</Menu.Button>
|
||||
<Menu.Items
|
||||
placement={placement}
|
||||
className={isHeader ? 'bg-gray-600' : 'min-w-[200px]'}
|
||||
|
|
|
@ -174,7 +174,7 @@ export function MobileLayout({ children, sidebar }: MobileLayoutProps) {
|
|||
</section>
|
||||
|
||||
<div className="pt-6 shrink-0">
|
||||
<UpgradePrompt />
|
||||
{process.env.STRIPE_API_KEY && <UpgradePrompt />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -13,7 +13,7 @@ export function Intro({ onNext, title }: StepProps) {
|
|||
<div className="flex justify-center">
|
||||
<Wave />
|
||||
</div>
|
||||
<h2 className="mt-6">{title}</h2>
|
||||
<h2 className="mt-6 text-pretty">{title}</h2>
|
||||
<p className="mt-2 text-base text-gray-50">
|
||||
We’re super excited you’re here. In the next step we’ll ask
|
||||
you a few questions to complete your profile and get you all set up.
|
||||
|
|
|
@ -116,7 +116,7 @@ function ProfileForm({ title, onSubmit, defaultValues }: ProfileViewProps) {
|
|||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<h3 className="text-center">{title}</h3>
|
||||
<h3 className="text-center text-pretty">{title}</h3>
|
||||
<p className="mt-4 text-base text-gray-50">
|
||||
We’ll need a few things from you to offer a personal experience when it comes
|
||||
to building plans or receiving advice from our advisors.
|
||||
|
|
|
@ -55,7 +55,7 @@ export function Welcome({ title: stepTitle, onNext }: StepProps) {
|
|||
>
|
||||
<div className="max-w-md grow">
|
||||
<img src="/assets/maybe.svg" className="h-8" alt="Maybe" />
|
||||
<h3 className="mt-14">{stepTitle}</h3>
|
||||
<h3 className="mt-14 text-pretty">{stepTitle}</h3>
|
||||
<p className="mt-2 text-base text-gray-50">
|
||||
We made you a little something to celebrate you taking your first steps in
|
||||
Maybe. Feel free to share and don’t forget to flip the card!
|
||||
|
|
|
@ -130,7 +130,7 @@ export function YourMaybe({ title, onNext }: StepProps) {
|
|||
<div className="flex justify-center">
|
||||
<Fire />
|
||||
</div>
|
||||
<h2 className="mt-6 text-center">{title}</h2>
|
||||
<h2 className="mt-6 text-center text-pretty">{title}</h2>
|
||||
<p className="mt-2 text-center text-base text-gray-50">
|
||||
A maybe is a goal or dream you’re considering, but have not yet fully
|
||||
committed to because you’re not yet sure if it’s financially
|
||||
|
|
|
@ -31,7 +31,7 @@ export function AddFirstAccount({ title, onNext }: StepProps) {
|
|||
unmount={false}
|
||||
>
|
||||
<div className="relative">
|
||||
<h3>{title}</h3>
|
||||
<h3 className="text-pretty">{title}</h3>
|
||||
<div className="text-base text-gray-50">
|
||||
<p className="mt-2">
|
||||
To get the most out of Maybe you need to add your financial
|
||||
|
|
|
@ -77,7 +77,7 @@ export function EmailVerification({ title, onNext }: StepProps) {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mt-12 text-center">
|
||||
<h3 className="mt-12 text-center text-pretty">
|
||||
{profile.data?.emailVerified ? 'Email verified' : title}
|
||||
</h3>
|
||||
<div className="text-base text-center">
|
||||
|
|
|
@ -105,7 +105,7 @@ export function OtherAccounts({ title, onNext }: StepProps) {
|
|||
<div className="min-h-[700px] overflow-x-hidden">
|
||||
<div className="flex max-w-5xl mx-auto gap-32 justify-center sm:justify-start">
|
||||
<div className="grow max-w-md sm:mt-12 text-center sm:text-start">
|
||||
<h3>{title}</h3>
|
||||
<h3 className="text-pretty">{title}</h3>
|
||||
<p className="mt-2 text-base text-gray-50">
|
||||
You can select bank accounts if there are any other accounts you’d
|
||||
like to add. Feel free to select more than one. We’ll add these to
|
||||
|
|
|
@ -78,7 +78,9 @@ export function BillingPreferences() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<UpgradeTakeover open={takeoverOpen} onClose={() => setTakeoverOpen(false)} />
|
||||
{process.env.STRIPE_API_KEY && (
|
||||
<UpgradeTakeover open={takeoverOpen} onClose={() => setTakeoverOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
export * from './useAccountApi'
|
||||
export * from './useAccountConnectionApi'
|
||||
export * from './useAuthUserApi'
|
||||
export * from './useFinicityApi'
|
||||
export * from './useInstitutionApi'
|
||||
export * from './useUserApi'
|
||||
export * from './usePlaidApi'
|
||||
|
|
|
@ -33,13 +33,6 @@ const AccountConnectionApi = (axios: AxiosInstance) => ({
|
|||
return data
|
||||
},
|
||||
|
||||
async createFinicityFixConnectUrl(id: SharedType.AccountConnection['id']) {
|
||||
const { data } = await axios.post<{ link: string }>(
|
||||
`/connections/${id}/finicity/fix-connect`
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
async disconnect(id: SharedType.AccountConnection['id']) {
|
||||
const { data } = await axios.post<SharedType.AccountConnection>(
|
||||
`/connections/${id}/disconnect`
|
||||
|
@ -119,10 +112,6 @@ export function useAccountConnectionApi() {
|
|||
const useCreatePlaidLinkToken = (mode: SharedType.PlaidLinkUpdateMode) =>
|
||||
useMutation((id: SharedType.AccountConnection['id']) => api.createPlaidLinkToken(id, mode))
|
||||
|
||||
const useCreateFinicityFixConnectUrl = (
|
||||
options?: UseMutationOptions<{ link: string }, unknown, number, unknown>
|
||||
) => useMutation(api.createFinicityFixConnectUrl, options)
|
||||
|
||||
const useDisconnectConnection = () =>
|
||||
useMutation(api.disconnect, {
|
||||
onSuccess: (data) => {
|
||||
|
@ -188,7 +177,6 @@ export function useAccountConnectionApi() {
|
|||
useDeleteConnection,
|
||||
useDeleteAllConnections,
|
||||
useCreatePlaidLinkToken,
|
||||
useCreateFinicityFixConnectUrl,
|
||||
useReconnectConnection,
|
||||
useDisconnectConnection,
|
||||
useSyncConnection,
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
import { useMemo } from 'react'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'
|
||||
|
||||
const FinicityApi = (axios: AxiosInstance) => ({
|
||||
async generateConnectUrl(institutionId: string) {
|
||||
const { data } = await axios.post<{ link: string }>('/finicity/connect-url', {
|
||||
institutionId,
|
||||
})
|
||||
return data.link
|
||||
},
|
||||
})
|
||||
|
||||
export function useFinicityApi() {
|
||||
const { axios } = useAxiosWithAuth()
|
||||
const api = useMemo(() => FinicityApi(axios), [axios])
|
||||
|
||||
const useGenerateConnectUrl = () => useMutation(api.generateConnectUrl)
|
||||
|
||||
return { useGenerateConnectUrl }
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
export * from './useAxiosWithAuth'
|
||||
export * from './useDebounce'
|
||||
export * from './useFinicity'
|
||||
export * from './useInterval'
|
||||
export * from './useLastUpdated'
|
||||
export * from './useLocalStorage'
|
||||
|
|
|
@ -1,70 +0,0 @@
|
|||
import { useCallback } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import * as Sentry from '@sentry/react'
|
||||
import type {
|
||||
ConnectCancelEvent,
|
||||
ConnectDoneEvent,
|
||||
ConnectErrorEvent,
|
||||
} from '@finicity/connect-web-sdk'
|
||||
import { useFinicityApi } from '../api'
|
||||
import { useAccountContext, useUserAccountContext } from '../providers'
|
||||
import { useLogger } from './useLogger'
|
||||
|
||||
export function useFinicity() {
|
||||
const logger = useLogger()
|
||||
|
||||
const { useGenerateConnectUrl } = useFinicityApi()
|
||||
const generateConnectUrl = useGenerateConnectUrl()
|
||||
|
||||
const { setExpectingAccounts } = useUserAccountContext()
|
||||
const { setAccountManager } = useAccountContext()
|
||||
|
||||
const launch = useCallback(
|
||||
async (linkOrPromise: string | Promise<string>) => {
|
||||
const toastId = toast.loading('Initializing Finicity...', { duration: 10_000 })
|
||||
|
||||
const [{ FinicityConnect }, link] = await Promise.all([
|
||||
import('@finicity/connect-web-sdk'),
|
||||
linkOrPromise,
|
||||
])
|
||||
|
||||
toast.dismiss(toastId)
|
||||
|
||||
FinicityConnect.launch(link, {
|
||||
onDone(evt: ConnectDoneEvent) {
|
||||
logger.debug(`Finicity Connect onDone event`, evt)
|
||||
setExpectingAccounts(true)
|
||||
},
|
||||
onError(evt: ConnectErrorEvent) {
|
||||
logger.error(`Finicity Connect exited with error`, evt)
|
||||
Sentry.captureEvent({
|
||||
level: 'error',
|
||||
message: 'FINICITY_CONNECT_ERROR',
|
||||
tags: {
|
||||
'finicity.error.code': evt.code,
|
||||
'finicity.error.reason': evt.reason,
|
||||
},
|
||||
})
|
||||
},
|
||||
onCancel(evt: ConnectCancelEvent) {
|
||||
logger.debug(`Finicity Connect onCancel event`, evt)
|
||||
},
|
||||
onUser() {
|
||||
// ...
|
||||
},
|
||||
onRoute() {
|
||||
// ...
|
||||
},
|
||||
})
|
||||
},
|
||||
[logger, setExpectingAccounts]
|
||||
)
|
||||
|
||||
return {
|
||||
launch,
|
||||
openFinicity: async (institutionId: string) => {
|
||||
launch(generateConnectUrl.mutateAsync(institutionId))
|
||||
setAccountManager({ view: 'idle' })
|
||||
},
|
||||
}
|
||||
}
|
|
@ -48,7 +48,6 @@ export type UpdateLiabilityFields = CreateLiabilityFields
|
|||
type AccountManager =
|
||||
| { view: 'idle' }
|
||||
| { view: 'add-plaid'; linkToken: string }
|
||||
| { view: 'add-finicity' }
|
||||
| { view: 'add-teller' }
|
||||
| { view: 'add-account' }
|
||||
| { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> }
|
||||
|
|
|
@ -42,6 +42,9 @@ export type ButtonProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'a
|
|||
className?: string
|
||||
|
||||
leftIcon?: ReactNode
|
||||
|
||||
/** Display spinner based on the value passed */
|
||||
isLoading?: boolean
|
||||
}>
|
||||
|
||||
function Button(
|
||||
|
@ -53,6 +56,7 @@ function Button(
|
|||
children,
|
||||
className,
|
||||
leftIcon,
|
||||
isLoading = false,
|
||||
...rest
|
||||
}: ButtonProps,
|
||||
ref: React.Ref<HTMLElement>
|
||||
|
@ -81,8 +85,43 @@ function Button(
|
|||
>
|
||||
{leftIcon && <span className="mr-1.5">{leftIcon}</span>}
|
||||
{children}
|
||||
{isLoading && Tag === 'button' && (
|
||||
<span className="ml-1.5">
|
||||
<Spinner fill={variant === 'primary' ? '#16161A' : '#FFFFFF'} />
|
||||
</span>
|
||||
)}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
// Add a spinner to Button component and toggle it via flag
|
||||
|
||||
interface SpinnerProps {
|
||||
fill: string
|
||||
}
|
||||
|
||||
function Spinner({ fill }: SpinnerProps): JSX.Element {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
||||
opacity=".25"
|
||||
fill={fill}
|
||||
/>
|
||||
<path
|
||||
d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z"
|
||||
fill={fill}
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
dur="0.75s"
|
||||
values="0 12 12;360 12 12"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.forwardRef(Button)
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"presets": [["@nrwl/web/babel", { "useBuiltIns": "usage" }]]
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"extends": ["../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
# finicity-api
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test finicity-api` to execute the unit tests via [Jest](https://jestjs.io).
|
|
@ -1,16 +0,0 @@
|
|||
/* eslint-disable */
|
||||
export default {
|
||||
displayName: 'finicity-api',
|
||||
preset: '../../jest.preset.js',
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
},
|
||||
},
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]sx?$': 'ts-jest',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
coverageDirectory: '../../coverage/libs/finicity-api',
|
||||
}
|
|
@ -1,294 +0,0 @@
|
|||
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
|
||||
import type {
|
||||
AddCustomerRequest,
|
||||
AddCustomerResponse,
|
||||
AuthenticationResponse,
|
||||
DeleteCustomerAccountsByInstitutionLoginRequest,
|
||||
DeleteCustomerAccountsByInstitutionLoginResponse,
|
||||
GenerateConnectUrlResponse,
|
||||
GenerateFixConnectUrlRequest,
|
||||
GenerateLiteConnectUrlRequest,
|
||||
GetAccountTransactionsRequest,
|
||||
GetAccountTransactionsResponse,
|
||||
GetCustomerAccountRequest,
|
||||
GetCustomerAccountResponse,
|
||||
GetCustomerAccountsRequest,
|
||||
GetCustomerAccountsResponse,
|
||||
GetInstitutionsRequest,
|
||||
GetInstitutionsResponse,
|
||||
LoadHistoricTransactionsRequest,
|
||||
RefreshCustomerAccountRequest,
|
||||
TxPushDisableRequest,
|
||||
TxPushSubscriptionRequest,
|
||||
TxPushSubscriptions,
|
||||
} from './types'
|
||||
import { DateTime } from 'luxon'
|
||||
import axios from 'axios'
|
||||
|
||||
const is2xx = (status: number): boolean => status >= 200 && status < 300
|
||||
|
||||
/**
|
||||
* Basic typed mapping for Finicity API
|
||||
*/
|
||||
export class FinicityApi {
|
||||
private api: AxiosInstance | null = null
|
||||
private tokenTimestamp: DateTime | null = null
|
||||
|
||||
constructor(
|
||||
private readonly appKey: string,
|
||||
private readonly partnerId: string,
|
||||
private readonly partnerSecret: string
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Search for supported financial institutions
|
||||
*
|
||||
* https://api-reference.finicity.com/#/rest/api-endpoints/institutions/get-institutions
|
||||
*/
|
||||
async getInstitutions(options: GetInstitutionsRequest): Promise<GetInstitutionsResponse> {
|
||||
return this.get<GetInstitutionsResponse>(`/institution/v2/institutions`, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enroll an active or testing customer
|
||||
*
|
||||
* https://api-reference.finicity.com/#/rest/api-endpoints/customer/add-customer
|
||||
*/
|
||||
async addCustomer(options: AddCustomerRequest): Promise<AddCustomerResponse> {
|
||||
return this.post<AddCustomerResponse>(`/aggregation/v2/customers/active`, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enroll a testing customer
|
||||
*
|
||||
* https://api-reference.finicity.com/#/rest/api-endpoints/customer/add-testing-customer
|
||||
*/
|
||||
async addTestingCustomer(options: AddCustomerRequest): Promise<AddCustomerResponse> {
|
||||
return this.post<AddCustomerResponse>(`/aggregation/v2/customers/testing`, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Connect Lite URL
|
||||
*
|
||||
* https://api-reference.finicity.com/#/rest/api-endpoints/connect/generate-v2-lite-connect-url
|
||||
*/
|
||||
async generateLiteConnectUrl(
|
||||
options: Omit<GenerateLiteConnectUrlRequest, 'partnerId'>
|
||||
): Promise<GenerateConnectUrlResponse> {
|
||||
return this.post<{ link: string }>(`/connect/v2/generate/lite`, {
|
||||
partnerId: this.partnerId,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Fix Connect URL
|
||||
*
|
||||
* https://api-reference.finicity.com/#/rest/api-endpoints/connect/generate-v2-fix-connect-url
|
||||
*/
|
||||
async generateFixConnectUrl(
|
||||
options: Omit<GenerateFixConnectUrlRequest, 'partnerId'>
|
||||
): Promise<GenerateConnectUrlResponse> {
|
||||
return this.post<GenerateConnectUrlResponse>(`/connect/v2/generate/fix`, {
|
||||
partnerId: this.partnerId,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get details for all accounts owned by a customer, optionally for a specific institution
|
||||
*
|
||||
* https://api-reference.finicity.com/#/rest/api-endpoints/accounts/get-customer-accounts
|
||||
*/
|
||||
async getCustomerAccounts(
|
||||
options: GetCustomerAccountsRequest
|
||||
): Promise<GetCustomerAccountsResponse> {
|
||||
const { customerId, ...rest } = options
|
||||
|
||||
return this.get<GetCustomerAccountsResponse>(
|
||||
`/aggregation/v2/customers/${customerId}/accounts`,
|
||||
rest,
|
||||
{
|
||||
validateStatus: (status) => is2xx(status) && status !== 203,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get details for an account
|
||||
*
|
||||
* https://api-reference.finicity.com/#/rest/api-endpoints/accounts/get-customer-account
|
||||
*/
|
||||
async getCustomerAccount(
|
||||
options: GetCustomerAccountRequest
|
||||
): Promise<GetCustomerAccountResponse> {
|
||||
const { customerId, accountId, ...rest } = options
|
||||
|
||||
return this.get<GetCustomerAccountResponse>(
|
||||
`/aggregation/v2/customers/${customerId}/accounts/${accountId}`,
|
||||
rest,
|
||||
{
|
||||
validateStatus: (status) => is2xx(status) && status !== 203,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh accounts
|
||||
*
|
||||
* https://api-reference.finicity.com/#/rest/api-endpoints/accounts/refresh-customer-accounts
|
||||
*/
|
||||
async refreshCustomerAccounts({
|
||||
customerId,
|
||||
}: RefreshCustomerAccountRequest): Promise<GetCustomerAccountsResponse> {
|
||||
return this.post<GetCustomerAccountsResponse>(
|
||||
`/aggregation/v1/customers/${customerId}/accounts`,
|
||||
undefined,
|
||||
{
|
||||
timeout: 120_000,
|
||||
validateStatus: (status) => is2xx(status) && status !== 203,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async deleteCustomerAccountsByInstitutionLogin(
|
||||
options: DeleteCustomerAccountsByInstitutionLoginRequest
|
||||
): Promise<DeleteCustomerAccountsByInstitutionLoginResponse> {
|
||||
const { customerId, institutionLoginId, ...rest } = options
|
||||
|
||||
return this.delete<DeleteCustomerAccountsByInstitutionLoginResponse>(
|
||||
`/aggregation/v1/customers/${customerId}/institutionLogins/${institutionLoginId}`,
|
||||
rest
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transactions for an account
|
||||
*
|
||||
* https://api-reference.finicity.com/#/rest/api-endpoints/transactions/get-customer-account-transactions
|
||||
*/
|
||||
async getAccountTransactions(
|
||||
options: GetAccountTransactionsRequest
|
||||
): Promise<GetAccountTransactionsResponse> {
|
||||
const { customerId, accountId, ...rest } = options
|
||||
|
||||
return this.get<GetAccountTransactionsResponse>(
|
||||
`/aggregation/v4/customers/${customerId}/accounts/${accountId}/transactions`,
|
||||
rest,
|
||||
{
|
||||
validateStatus: (status) => is2xx(status) && status !== 203,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load historic transactions for an account
|
||||
*
|
||||
* https://api-reference.finicity.com/#/rest/api-endpoints/accounts/load-historic-transactions-for-customer-account
|
||||
*/
|
||||
async loadHistoricTransactions({
|
||||
customerId,
|
||||
accountId,
|
||||
}: LoadHistoricTransactionsRequest): Promise<void> {
|
||||
await this.post(
|
||||
`/aggregation/v1/customers/${customerId}/accounts/${accountId}/transactions/historic`,
|
||||
{},
|
||||
{
|
||||
timeout: 180_000,
|
||||
validateStatus: (status) => is2xx(status) && status !== 203,
|
||||
} // 180 second timeout recommended by Finicity
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to TxPUSH notifications
|
||||
*
|
||||
* https://api-reference.finicity.com/#/rest/api-endpoints/txpush/subscribe-to-txpush-notifications
|
||||
*/
|
||||
async subscribeTxPush({
|
||||
customerId,
|
||||
accountId,
|
||||
callbackUrl,
|
||||
}: TxPushSubscriptionRequest): Promise<TxPushSubscriptions> {
|
||||
return this.post(`/aggregation/v1/customers/${customerId}/accounts/${accountId}/txpush`, {
|
||||
callbackUrl,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable TxPUSH notifications
|
||||
*
|
||||
* https://api-reference.finicity.com/#/rest/api-endpoints/txpush/disable-txpush-notifications
|
||||
*/
|
||||
async disableTxPush({ customerId, accountId }: TxPushDisableRequest): Promise<void> {
|
||||
await this.delete(`/aggregation/v1/customers/${customerId}/accounts/${accountId}/txpush`)
|
||||
}
|
||||
|
||||
private async getApi(): Promise<AxiosInstance> {
|
||||
const tokenAge =
|
||||
this.tokenTimestamp && Math.abs(this.tokenTimestamp.diffNow('minutes').minutes)
|
||||
|
||||
// Refresh token if over 90 minutes old (https://api-reference.finicity.com/#/rest/api-endpoints/authentication/partner-authentication)
|
||||
if (!this.api || !tokenAge || (tokenAge && tokenAge > 90)) {
|
||||
const token = (
|
||||
await axios.post<AuthenticationResponse>(
|
||||
'https://api.finicity.com/aggregation/v2/partners/authentication',
|
||||
{
|
||||
partnerId: this.partnerId,
|
||||
partnerSecret: this.partnerSecret,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Finicity-App-Key': this.appKey,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
).data.token
|
||||
|
||||
this.tokenTimestamp = DateTime.now()
|
||||
|
||||
this.api = axios.create({
|
||||
baseURL: `https://api.finicity.com`,
|
||||
timeout: 30_000,
|
||||
headers: {
|
||||
'Finicity-App-Token': token,
|
||||
'Finicity-App-Key': this.appKey,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return this.api
|
||||
}
|
||||
|
||||
/** Generic API GET request method */
|
||||
private async get<TResponse>(
|
||||
path: string,
|
||||
params?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<TResponse> {
|
||||
const api = await this.getApi()
|
||||
return api.get<TResponse>(path, { params, ...config }).then(({ data }) => data)
|
||||
}
|
||||
|
||||
/** Generic API POST request method */
|
||||
private async post<TResponse>(
|
||||
path: string,
|
||||
body?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<TResponse> {
|
||||
const api = await this.getApi()
|
||||
return api.post<TResponse>(path, body, config).then(({ data }) => data)
|
||||
}
|
||||
|
||||
/** Generic API DELETE request method */
|
||||
private async delete<TResponse>(
|
||||
path: string,
|
||||
params?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<TResponse> {
|
||||
const api = await this.getApi()
|
||||
return api.delete<TResponse>(path, { params, ...config }).then(({ data }) => data)
|
||||
}
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
export * from './finicity-api'
|
||||
export * as FinicityTypes from './types'
|
|
@ -1,163 +0,0 @@
|
|||
/** https://api-reference.finicity.com/#/rest/models/enumerations/account-type */
|
||||
export type AccountType =
|
||||
| 'checking'
|
||||
| 'savings'
|
||||
| 'cd'
|
||||
| 'moneyMarket'
|
||||
| 'creditCard'
|
||||
| 'lineOfCredit'
|
||||
| 'investment'
|
||||
| 'brokerageAccount'
|
||||
| 'pension'
|
||||
| 'profitSharingPlan'
|
||||
| 'investmentTaxDeferred'
|
||||
| 'employeeStockPurchasePlan'
|
||||
| 'ira'
|
||||
| 'simpleIRA'
|
||||
| 'sepIRA'
|
||||
| '401k'
|
||||
| 'roth'
|
||||
| 'roth401k'
|
||||
| '403b'
|
||||
| '529'
|
||||
| '529plan'
|
||||
| 'rollover'
|
||||
| 'ugma'
|
||||
| 'utma'
|
||||
| 'keogh'
|
||||
| '457'
|
||||
| '457plan'
|
||||
| '401a'
|
||||
| 'cryptocurrency'
|
||||
| 'mortgage'
|
||||
| 'loan'
|
||||
| 'studentLoan'
|
||||
| 'studentLoanGroup'
|
||||
| 'studentLoanAccount'
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/models/structures/customer-account-position */
|
||||
export type CustomerAccountPosition = {
|
||||
[key: string]: any
|
||||
id?: number
|
||||
description?: string
|
||||
securityId?: string
|
||||
securityIdType?: string
|
||||
symbol?: string
|
||||
/** @deprecated finicity still uses this field in lieu of `units` for some accounts (eg. Citibank) as of 2023-01-30 */
|
||||
quantity?: number
|
||||
units?: number
|
||||
currentPrice?: number
|
||||
securityName?: string
|
||||
/** @deprecated undocumented field */
|
||||
fundName?: string
|
||||
transactionType?: string
|
||||
marketValue?: number | string
|
||||
costBasis?: number
|
||||
status?: string
|
||||
currentPriceDate?: number
|
||||
invSecurityType?: string
|
||||
mfType?: string
|
||||
posType?: string
|
||||
totalGLDollar?: number
|
||||
totalGLPercent?: number
|
||||
securityType?: string
|
||||
securityCurrency?: string
|
||||
fiAssetClass?: string
|
||||
assetClass?: string
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/models/structures/customer-account-detail */
|
||||
export type CustomerAccountDetail = {
|
||||
[key: string]: any
|
||||
availableBalanceAmount?: number
|
||||
availableCashBalance?: number
|
||||
interestRate?: string
|
||||
creditAvailableAmount?: number
|
||||
paymentMinAmount?: number
|
||||
statementCloseBalance?: number
|
||||
locPrincipalBalance?: number
|
||||
paymentDueDate?: number
|
||||
statementEndDate?: number
|
||||
vestedBalance?: number
|
||||
currentLoanBalance?: number
|
||||
payoffAmount?: number
|
||||
principalBalance?: number
|
||||
autoPayEnrolled?: 'Y' | 'N'
|
||||
firstMortgage?: 'Y' | 'N'
|
||||
recurringPaymentAmount?: number
|
||||
lender?: string
|
||||
endingBalanceAmount?: number
|
||||
loanTermType?: string
|
||||
paymentsMade?: number
|
||||
balloonAmount?: number
|
||||
paymentsRemaining?: number
|
||||
loanMinAmtDue?: number
|
||||
loanPaymentFreq?: string
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/models/structures/customer-account */
|
||||
export type CustomerAccount = {
|
||||
[key: string]: any
|
||||
id: string
|
||||
accountNumberDisplay: string
|
||||
realAccountNumberLast4?: string
|
||||
name: string
|
||||
balance?: number
|
||||
type: AccountType
|
||||
aggregationStatusCode?: number
|
||||
status: string
|
||||
customerId: string
|
||||
institutionId: string
|
||||
balanceDate: number
|
||||
aggregationSuccessDate?: number
|
||||
aggregationAttemptDate?: number
|
||||
createdDate: number
|
||||
currency: string
|
||||
lastTransactionDate?: number
|
||||
/** Incorrectly shown as "Required" in Finicity docs */
|
||||
oldestTransactionDate?: number
|
||||
institutionLoginId: number
|
||||
detail?: CustomerAccountDetail
|
||||
position?: CustomerAccountPosition[]
|
||||
displayPosition: number
|
||||
parentAccount?: number
|
||||
|
||||
/** Not in Finicity docs */
|
||||
accountNickname?: string
|
||||
/** Not in Finicity docs */
|
||||
marketSegment?: string
|
||||
|
||||
/** @deprecated */
|
||||
number?: string
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/api-endpoints/accounts/get-customer-accounts */
|
||||
export type GetCustomerAccountsRequest = {
|
||||
customerId: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/models/structures/customer-accounts */
|
||||
export type GetCustomerAccountsResponse = {
|
||||
accounts: CustomerAccount[]
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/api-endpoints/accounts/get-customer-account */
|
||||
export type GetCustomerAccountRequest = {
|
||||
customerId: string
|
||||
accountId: number
|
||||
}
|
||||
|
||||
export type GetCustomerAccountResponse = CustomerAccount
|
||||
|
||||
export type RefreshCustomerAccountRequest = {
|
||||
customerId: string | number
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/api-endpoints/accounts/delete-customer-accounts-by-institution-login */
|
||||
export type DeleteCustomerAccountsByInstitutionLoginRequest = {
|
||||
customerId: string
|
||||
institutionLoginId: number
|
||||
}
|
||||
|
||||
export type DeleteCustomerAccountsByInstitutionLoginResponse = void
|
|
@ -1,4 +0,0 @@
|
|||
/** https://api-reference.finicity.com/#/rest/models/structures/authentication-response */
|
||||
export type AuthenticationResponse = {
|
||||
token: string
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
/** https://api-reference.finicity.com/#/rest/models/structures/generate-connect-url-request-lite-v2 */
|
||||
export type GenerateLiteConnectUrlRequest = {
|
||||
partnerId: string
|
||||
customerId: string
|
||||
institutionId: string
|
||||
redirectUri?: string
|
||||
webhook?: string
|
||||
webhookContentType?: string
|
||||
webhookData?: object
|
||||
webhookHeaders?: object
|
||||
experience?: string
|
||||
singleUseUrl?: boolean
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/models/structures/generate-connect-url-request-fix-v2 */
|
||||
export type GenerateFixConnectUrlRequest = {
|
||||
partnerId: string
|
||||
customerId: string
|
||||
institutionLoginId: string | number
|
||||
redirectUri?: string
|
||||
webhook?: string
|
||||
webhookContentType?: string
|
||||
webhookData?: object
|
||||
webhookHeaders?: object
|
||||
experience?: string
|
||||
singleUseUrl?: boolean
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/models/structures/generate-connect-url-response */
|
||||
export type GenerateConnectUrlResponse = {
|
||||
link: string
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
/** https://api-reference.finicity.com/#/rest/models/structures/add-customer-request */
|
||||
export type AddCustomerRequest = {
|
||||
username: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
applicationId?: string
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/models/structures/add-customer-response */
|
||||
export type AddCustomerResponse = {
|
||||
id: string
|
||||
username: string
|
||||
createdDate: string
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
export * from './accounts'
|
||||
export * from './authentication'
|
||||
export * from './connect'
|
||||
export * from './customers'
|
||||
export * from './institutions'
|
||||
export * from './transactions'
|
||||
export * from './webhooks'
|
||||
export * from './txpush'
|
|
@ -1,65 +0,0 @@
|
|||
/** https://api-reference.finicity.com/#/rest/models/structures/institution-address */
|
||||
export type InstitutionAddress = {
|
||||
city?: string
|
||||
state?: string
|
||||
country?: string
|
||||
postalCode?: string
|
||||
addressLine1?: string
|
||||
addressLine2?: string
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/models/structures/get-institutions-institution-branding */
|
||||
export type InstitutionBranding = {
|
||||
logo?: string
|
||||
alternateLogo?: string
|
||||
icon?: string
|
||||
primaryColor?: string
|
||||
tile?: string
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/models/structures/institution */
|
||||
export type Institution = {
|
||||
id: number
|
||||
name?: string
|
||||
transAgg: boolean
|
||||
ach: boolean
|
||||
stateAgg: boolean
|
||||
voi: boolean
|
||||
voa: boolean
|
||||
aha: boolean
|
||||
availBalance: boolean
|
||||
accountOwner: boolean
|
||||
accountTypeDescription?: string
|
||||
phone?: string
|
||||
urlHomeApp?: string
|
||||
urlLogonApp?: string
|
||||
oauthEnabled: boolean
|
||||
urlForgotPassword?: string
|
||||
urlOnlineRegistration?: string
|
||||
class?: string
|
||||
specialText?: string
|
||||
specialInstructions?: string[]
|
||||
address?: InstitutionAddress
|
||||
currency: string
|
||||
email?: string
|
||||
status: string
|
||||
newInstitutionId?: number
|
||||
branding?: InstitutionBranding
|
||||
oauthInstitutionId?: number
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/api-endpoints/institutions/get-institutions */
|
||||
export type GetInstitutionsRequest = {
|
||||
search?: string
|
||||
start?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/models/structures/get-institutions-response */
|
||||
export type GetInstitutionsResponse = {
|
||||
found: number
|
||||
displaying: number
|
||||
moreAvailable: boolean
|
||||
createdDate: string
|
||||
institutions: Institution[]
|
||||
}
|
|
@ -1,98 +0,0 @@
|
|||
/** https://api-reference.finicity.com/#/rest/models/enumerations/transaction-type */
|
||||
export type TransactionType =
|
||||
| 'atm'
|
||||
| 'cash'
|
||||
| 'check'
|
||||
| 'credit'
|
||||
| 'debit'
|
||||
| 'deposit'
|
||||
| 'directDebit'
|
||||
| 'directDeposit'
|
||||
| 'dividend'
|
||||
| 'fee'
|
||||
| 'interest'
|
||||
| 'other'
|
||||
| 'payment'
|
||||
| 'pointOfSale'
|
||||
| 'repeatPayment'
|
||||
| 'serviceCharge'
|
||||
| 'transfer'
|
||||
| 'DIV' // undocumented
|
||||
| 'SRVCHG' // undocumented
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/models/structures/categorization */
|
||||
export type TransactionCategorization = {
|
||||
[key: string]: any
|
||||
normalizedPayeeName: string
|
||||
/** https://api-reference.finicity.com/#/rest/models/enumerations/categories */
|
||||
category: string
|
||||
city?: string
|
||||
state?: string
|
||||
postalCode?: string
|
||||
country: string
|
||||
bestRepresentation?: string
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/models/structures/transaction */
|
||||
export type Transaction = {
|
||||
[key: string]: any
|
||||
id: number
|
||||
amount: number
|
||||
accountId: number
|
||||
customerId: number
|
||||
status: 'active' | 'pending' | 'shadow'
|
||||
description: string
|
||||
memo?: string
|
||||
postedDate: number
|
||||
transactionDate?: number
|
||||
effectiveDate?: number
|
||||
firstEffectiveDate?: number
|
||||
createdDate: number
|
||||
type?: TransactionType | string
|
||||
checkNum?: number
|
||||
escrowAmount?: number
|
||||
feeAmount?: number
|
||||
interestAmount?: number
|
||||
principalAmount?: number
|
||||
unitQuantity?: number
|
||||
unitPrice?: number
|
||||
categorization?: TransactionCategorization
|
||||
subaccountSecurityType?: string
|
||||
commissionAmount?: number
|
||||
symbol?: string
|
||||
ticker?: string
|
||||
investmentTransactionType?: string
|
||||
taxesAmount?: number
|
||||
currencySymbol?: string
|
||||
securityId?: string
|
||||
securityIdType?: string
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/api-endpoints/transactions/get-customer-account-transactions */
|
||||
export type GetAccountTransactionsRequest = {
|
||||
customerId: string
|
||||
accountId: string
|
||||
fromDate: number
|
||||
toDate: number
|
||||
start?: number
|
||||
limit?: number
|
||||
sort?: 'asc' | 'desc'
|
||||
includePending?: boolean
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/models/structures/get-transactions-response */
|
||||
export type GetAccountTransactionsResponse = {
|
||||
found: number
|
||||
displaying: number
|
||||
moreAvailable: string
|
||||
fromDate: string
|
||||
toDate: string
|
||||
sort: string
|
||||
transactions: Transaction[]
|
||||
}
|
||||
|
||||
/** https://api-reference.finicity.com/#/rest/api-endpoints/accounts/load-historic-transactions-for-customer-account */
|
||||
export type LoadHistoricTransactionsRequest = {
|
||||
customerId: string
|
||||
accountId: string
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
import type { CustomerAccount } from './accounts'
|
||||
import type { Transaction } from './transactions'
|
||||
|
||||
export type TxPushSubscriptionRequest = {
|
||||
customerId: string | number
|
||||
accountId: string | number
|
||||
callbackUrl: string
|
||||
}
|
||||
|
||||
type SubscriptionRecord = {
|
||||
id: number
|
||||
accountId: number
|
||||
type: 'account' | 'transaction'
|
||||
callbackUrl: string
|
||||
signingKey: string
|
||||
}
|
||||
|
||||
export type TxPushSubscriptions = {
|
||||
subscriptions: SubscriptionRecord[]
|
||||
}
|
||||
|
||||
export type TxPushEvent =
|
||||
| {
|
||||
class: 'transaction'
|
||||
type: 'created' | 'modified' | 'deleted'
|
||||
records: Transaction[]
|
||||
}
|
||||
| {
|
||||
class: 'account'
|
||||
type: 'modified' | 'deleted'
|
||||
records: CustomerAccount[]
|
||||
}
|
||||
|
||||
export type TxPushEventMessage = {
|
||||
event: TxPushEvent
|
||||
}
|
||||
|
||||
export type TxPushDisableRequest = {
|
||||
customerId: string | number
|
||||
accountId: string | number
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
import type { CustomerAccount } from './accounts'
|
||||
|
||||
/** https://docs.finicity.com/webhook-events-list/#webhooks-2-3 */
|
||||
export type WebhookData =
|
||||
| {
|
||||
eventType: 'ping'
|
||||
}
|
||||
| {
|
||||
eventType: 'added' | 'discovered'
|
||||
payload: {
|
||||
accounts: CustomerAccount[]
|
||||
institutionId: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
eventType: 'done'
|
||||
customerId: string
|
||||
}
|
||||
| {
|
||||
eventType: 'institutionNotFound'
|
||||
payload: {
|
||||
query: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
eventType: 'institutionNotSupported'
|
||||
payload: {
|
||||
institutionId: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
eventType: 'unableToConnect'
|
||||
payload: {
|
||||
institutionId: string
|
||||
code: number
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
|
||||
"include": ["**/*.ts"]
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.jsx",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.d.ts",
|
||||
"jest.config.ts"
|
||||
]
|
||||
}
|
|
@ -71,7 +71,6 @@ export class InvestmentTransactionBalanceSyncStrategy extends BalanceSyncStrateg
|
|||
AND it.date BETWEEN ${pStart} AND now()
|
||||
AND ( -- filter for transactions that modify a position
|
||||
it.plaid_type IN ('buy', 'sell', 'transfer')
|
||||
OR it.finicity_transaction_id IS NOT NULL
|
||||
)
|
||||
GROUP BY
|
||||
1, 2
|
||||
|
|
|
@ -153,13 +153,7 @@ export class AccountConnectionService implements IAccountConnectionService {
|
|||
])
|
||||
|
||||
this.logger.info(
|
||||
`Disconnected connection id=${connection.id} type=${
|
||||
connection.type
|
||||
} provider_connection_id=${
|
||||
connection.type === 'plaid'
|
||||
? connection.plaidItemId
|
||||
: connection.finicityInstitutionId
|
||||
}`
|
||||
`Disconnected connection id=${connection.id} type=${connection.type} provider_connection_id=${connection.plaidItemId}`
|
||||
)
|
||||
|
||||
return connection
|
||||
|
@ -182,13 +176,7 @@ export class AccountConnectionService implements IAccountConnectionService {
|
|||
])
|
||||
|
||||
this.logger.info(
|
||||
`Reconnected connection id=${connection.id} type=${
|
||||
connection.type
|
||||
} provider_connection_id=${
|
||||
connection.type === 'plaid'
|
||||
? connection.plaidItemId
|
||||
: connection.finicityInstitutionId
|
||||
}`
|
||||
`Reconnected connection id=${connection.id} type=${connection.type} provider_connection_id=${connection.plaidItemId}`
|
||||
)
|
||||
|
||||
return connection
|
||||
|
@ -213,13 +201,7 @@ export class AccountConnectionService implements IAccountConnectionService {
|
|||
})
|
||||
|
||||
this.logger.info(
|
||||
`Deleted connection id=${deletedConnection.id} type=${
|
||||
connection.type
|
||||
} provider_connection_id=${
|
||||
connection.type === 'plaid'
|
||||
? connection.plaidItemId
|
||||
: connection.finicityInstitutionId
|
||||
}`
|
||||
`Deleted connection id=${deletedConnection.id} type=${connection.type} provider_connection_id=${connection.plaidItemId}`
|
||||
)
|
||||
|
||||
return deletedConnection
|
||||
|
|
|
@ -246,7 +246,6 @@ export class AccountQueryService implements IAccountQueryService {
|
|||
(it.plaid_type = 'cash' AND it.plaid_subtype IN ('contribution', 'deposit', 'withdrawal'))
|
||||
OR (it.plaid_type = 'transfer' AND it.plaid_subtype IN ('transfer'))
|
||||
OR (it.plaid_type = 'buy' AND it.plaid_subtype IN ('contribution'))
|
||||
OR (it.finicity_transaction_id IS NOT NULL AND it.finicity_investment_transaction_type IN ('contribution', 'deposit', 'transfer'))
|
||||
)
|
||||
GROUP BY
|
||||
1, 2
|
||||
|
|
|
@ -194,10 +194,6 @@ export const AccountUpdateSchema = z.discriminatedUnion('provider', [
|
|||
provider: z.literal('plaid'),
|
||||
data: ProviderAccountUpdateSchema,
|
||||
}),
|
||||
z.object({
|
||||
provider: z.literal('finicity'),
|
||||
data: ProviderAccountUpdateSchema,
|
||||
}),
|
||||
z.object({
|
||||
provider: z.literal('user'),
|
||||
data: UserAccountUpdateSchema,
|
||||
|
|
|
@ -312,9 +312,6 @@ export class InsightService implements IInsightService {
|
|||
{
|
||||
plaidSubtype: 'dividend',
|
||||
},
|
||||
{
|
||||
finicityInvestmentTransactionType: 'dividend',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
@ -649,9 +646,6 @@ export class InsightService implements IInsightService {
|
|||
WHEN plaid_type IN ('fixed income') THEN 'fixed_income'
|
||||
WHEN plaid_type IN ('cash', 'loan') THEN 'cash'
|
||||
WHEN plaid_type IN ('cryptocurrency') THEN 'crypto'
|
||||
-- finicity
|
||||
WHEN finicity_type IN ('EQUITY', 'ETF', 'MUTUALFUND', 'STOCKINFO', 'MFINFO') THEN 'stocks'
|
||||
WHEN finicity_type IN ('BOND') THEN 'fixed_income'
|
||||
ELSE 'other'
|
||||
END AS "asset_class"
|
||||
FROM
|
||||
|
@ -705,9 +699,6 @@ export class InsightService implements IInsightService {
|
|||
WHEN s.plaid_type IN ('fixed income') THEN 'fixed_income'
|
||||
WHEN s.plaid_type IN ('cash', 'loan') THEN 'cash'
|
||||
WHEN s.plaid_type IN ('cryptocurrency') THEN 'crypto'
|
||||
-- finicity
|
||||
WHEN s.finicity_type IN ('EQUITY', 'ETF', 'MUTUALFUND', 'STOCKINFO', 'MFINFO') THEN 'stocks'
|
||||
WHEN s.finicity_type IN ('BOND') THEN 'fixed_income'
|
||||
ELSE 'other'
|
||||
END AS "category"
|
||||
) x ON TRUE
|
||||
|
@ -750,7 +741,6 @@ export class InsightService implements IInsightService {
|
|||
(it.plaid_type = 'cash' AND it.plaid_subtype IN ('contribution', 'deposit', 'withdrawal'))
|
||||
OR (it.plaid_type = 'transfer' AND it.plaid_subtype IN ('transfer', 'send', 'request'))
|
||||
OR (it.plaid_type = 'buy' AND it.plaid_subtype IN ('contribution'))
|
||||
OR (it.finicity_transaction_id IS NOT NULL AND it.finicity_investment_transaction_type IN ('contribution', 'deposit', 'transfer'))
|
||||
)
|
||||
-- Exclude any contributions made prior to the start date since balances will be 0
|
||||
AND (a.start_date is NULL OR it.date >= a.start_date)
|
||||
|
@ -854,9 +844,6 @@ export class InsightService implements IInsightService {
|
|||
WHEN plaid_type IN ('fixed income') THEN 'bonds'
|
||||
WHEN plaid_type IN ('cash', 'loan') THEN 'cash'
|
||||
WHEN plaid_type IN ('cryptocurrency') THEN 'crypto'
|
||||
-- finicity
|
||||
WHEN finicity_type IN ('EQUITY', 'ETF', 'MUTUALFUND', 'STOCKINFO', 'MFINFO') THEN 'stocks'
|
||||
WHEN finicity_type IN ('BOND') THEN 'bonds'
|
||||
ELSE 'other'
|
||||
END AS "asset_type"
|
||||
FROM
|
||||
|
|
|
@ -1,662 +0,0 @@
|
|||
import type { AccountConnection, PrismaClient } from '@prisma/client'
|
||||
import type { Logger } from 'winston'
|
||||
import { SharedUtil, AccountUtil, type SharedType } from '@maybe-finance/shared'
|
||||
import type { FinicityApi, FinicityTypes } from '@maybe-finance/finicity-api'
|
||||
import { DbUtil, FinicityUtil, type IETL } from '@maybe-finance/server/shared'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import _ from 'lodash'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
type FinicitySecurity = {
|
||||
securityName: string | undefined
|
||||
symbol: string | undefined
|
||||
currentPrice: number | undefined
|
||||
currentPriceDate: number | undefined
|
||||
securityId: string
|
||||
securityIdType: string
|
||||
type: string | undefined
|
||||
assetClass: string | undefined
|
||||
fiAssetClass: string | undefined
|
||||
}
|
||||
|
||||
export type FinicityRawData = {
|
||||
accounts: FinicityTypes.CustomerAccount[]
|
||||
transactions: FinicityTypes.Transaction[]
|
||||
transactionsDateRange: SharedType.DateRange<DateTime>
|
||||
}
|
||||
|
||||
export type FinicityData = {
|
||||
accounts: FinicityTypes.CustomerAccount[]
|
||||
positions: (FinicityTypes.CustomerAccountPosition & {
|
||||
accountId: FinicityTypes.CustomerAccount['id']
|
||||
security: FinicitySecurity
|
||||
})[]
|
||||
transactions: FinicityTypes.Transaction[]
|
||||
transactionsDateRange: SharedType.DateRange<DateTime>
|
||||
investmentTransactions: (FinicityTypes.Transaction & {
|
||||
security: Pick<FinicitySecurity, 'securityId' | 'securityIdType' | 'symbol'> | null
|
||||
})[]
|
||||
investmentTransactionsDateRange: SharedType.DateRange<DateTime>
|
||||
}
|
||||
|
||||
type Connection = Pick<
|
||||
AccountConnection,
|
||||
'id' | 'userId' | 'finicityInstitutionId' | 'finicityInstitutionLoginId'
|
||||
>
|
||||
|
||||
/**
|
||||
* Determines if a Finicity Transaction should be treated as an investment_transaction
|
||||
*/
|
||||
function isInvestmentTransaction(
|
||||
t: Pick<
|
||||
FinicityTypes.Transaction,
|
||||
'securityId' | 'symbol' | 'ticker' | 'investmentTransactionType'
|
||||
>
|
||||
) {
|
||||
return (
|
||||
t.securityId != null ||
|
||||
t.symbol != null ||
|
||||
t.ticker != null ||
|
||||
t.investmentTransactionType != null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes Finicity identifiers to handle cases where transactions/positions don't contain a valid
|
||||
* securityId/securityIdType pair
|
||||
*/
|
||||
function getSecurityIdAndType(
|
||||
txnOrPos: Pick<
|
||||
FinicityTypes.Transaction | FinicityTypes.CustomerAccountPosition,
|
||||
'securityId' | 'securityIdType' | 'symbol' | 'ticker'
|
||||
>
|
||||
): { securityId: string; securityIdType: string } | null {
|
||||
const securityId: string | null | undefined =
|
||||
txnOrPos.securityId || txnOrPos.symbol || txnOrPos.ticker
|
||||
|
||||
if (!securityId) return null
|
||||
|
||||
const securityIdType =
|
||||
txnOrPos.securityIdType ||
|
||||
(txnOrPos.securityId ? '__SECURITY_ID__' : txnOrPos.symbol ? '__SYMBOL__' : '__TICKER__')
|
||||
|
||||
return {
|
||||
securityId,
|
||||
securityIdType,
|
||||
}
|
||||
}
|
||||
|
||||
/** returns unique identifier for a given security (used for de-duping) */
|
||||
function getSecurityId(s: Pick<FinicitySecurity, 'securityId' | 'securityIdType'>): string {
|
||||
return `${s.securityIdType}|${s.securityId}`
|
||||
}
|
||||
|
||||
export class FinicityETL implements IETL<Connection, FinicityRawData, FinicityData> {
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly finicity: Pick<
|
||||
FinicityApi,
|
||||
'getCustomerAccounts' | 'getAccountTransactions'
|
||||
>
|
||||
) {}
|
||||
|
||||
async extract(connection: Connection): Promise<FinicityRawData> {
|
||||
if (!connection.finicityInstitutionId || !connection.finicityInstitutionLoginId) {
|
||||
throw new Error(
|
||||
`connection ${connection.id} is missing finicityInstitutionId or finicityInstitutionLoginId`
|
||||
)
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: connection.userId },
|
||||
select: {
|
||||
id: true,
|
||||
finicityCustomerId: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!user.finicityCustomerId) {
|
||||
throw new Error(`user ${user.id} is missing finicityCustomerId`)
|
||||
}
|
||||
|
||||
const transactionsDateRange = {
|
||||
start: DateTime.now().minus(FinicityUtil.FINICITY_WINDOW_MAX),
|
||||
end: DateTime.now(),
|
||||
}
|
||||
|
||||
const accounts = await this._extractAccounts(
|
||||
user.finicityCustomerId,
|
||||
connection.finicityInstitutionLoginId
|
||||
)
|
||||
|
||||
const transactions = await this._extractTransactions(
|
||||
user.finicityCustomerId,
|
||||
accounts.map((a) => a.id),
|
||||
transactionsDateRange
|
||||
)
|
||||
|
||||
this.logger.info(
|
||||
`Extracted Finicity data for customer ${user.finicityCustomerId} accounts=${accounts.length} transactions=${transactions.length}`,
|
||||
{ connection: connection.id, transactionsDateRange }
|
||||
)
|
||||
|
||||
return {
|
||||
accounts,
|
||||
transactions,
|
||||
transactionsDateRange,
|
||||
}
|
||||
}
|
||||
|
||||
transform(
|
||||
connection: Connection,
|
||||
{ accounts, transactions, transactionsDateRange }: FinicityRawData
|
||||
): Promise<FinicityData> {
|
||||
const positions = accounts.flatMap(
|
||||
(a) =>
|
||||
a.position
|
||||
?.filter((p) => p.securityId != null || p.symbol != null)
|
||||
.map((p) => ({
|
||||
...p,
|
||||
accountId: a.id,
|
||||
marketValue: p.marketValue ? +p.marketValue || 0 : 0,
|
||||
security: {
|
||||
...getSecurityIdAndType(p)!,
|
||||
securityName: p.securityName ?? p.fundName,
|
||||
symbol: p.symbol,
|
||||
currentPrice: p.currentPrice,
|
||||
currentPriceDate: p.currentPriceDate,
|
||||
type: p.securityType,
|
||||
assetClass: p.assetClass,
|
||||
fiAssetClass: p.fiAssetClass,
|
||||
},
|
||||
})) ?? []
|
||||
)
|
||||
|
||||
const [_investmentTransactions, _transactions] = _(transactions)
|
||||
.uniqBy((t) => t.id)
|
||||
.partition((t) => isInvestmentTransaction(t))
|
||||
.value()
|
||||
|
||||
this.logger.info(
|
||||
`Transformed Finicity transactions positions=${positions.length} transactions=${_transactions.length} investment_transactions=${_investmentTransactions.length}`,
|
||||
{ connection: connection.id }
|
||||
)
|
||||
|
||||
return Promise.resolve<FinicityData>({
|
||||
accounts,
|
||||
positions,
|
||||
transactions: _transactions,
|
||||
transactionsDateRange,
|
||||
investmentTransactions: _investmentTransactions.map((it) => {
|
||||
const security = getSecurityIdAndType(it)
|
||||
return {
|
||||
...it,
|
||||
security: security
|
||||
? {
|
||||
...security,
|
||||
symbol: it.symbol || it.ticker,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}),
|
||||
investmentTransactionsDateRange: transactionsDateRange,
|
||||
})
|
||||
}
|
||||
|
||||
async load(connection: Connection, data: FinicityData): Promise<void> {
|
||||
await this.prisma.$transaction([
|
||||
...this._loadAccounts(connection, data),
|
||||
...this._loadPositions(connection, data),
|
||||
...this._loadTransactions(connection, data),
|
||||
...this._loadInvestmentTransactions(connection, data),
|
||||
])
|
||||
|
||||
this.logger.info(`Loaded Finicity data for connection ${connection.id}`, {
|
||||
connection: connection.id,
|
||||
})
|
||||
}
|
||||
|
||||
private async _extractAccounts(customerId: string, institutionLoginId: string) {
|
||||
const { accounts } = await this.finicity.getCustomerAccounts({ customerId })
|
||||
|
||||
return accounts.filter(
|
||||
(a) => a.institutionLoginId.toString() === institutionLoginId && a.currency === 'USD'
|
||||
)
|
||||
}
|
||||
|
||||
private _loadAccounts(connection: Connection, { accounts }: Pick<FinicityData, 'accounts'>) {
|
||||
return [
|
||||
// upsert accounts
|
||||
...accounts.map((finicityAccount) => {
|
||||
const type = FinicityUtil.getType(finicityAccount)
|
||||
const classification = AccountUtil.getClassification(type)
|
||||
|
||||
return this.prisma.account.upsert({
|
||||
where: {
|
||||
accountConnectionId_finicityAccountId: {
|
||||
accountConnectionId: connection.id,
|
||||
finicityAccountId: finicityAccount.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
accountConnectionId: connection.id,
|
||||
finicityAccountId: finicityAccount.id,
|
||||
type: FinicityUtil.getType(finicityAccount),
|
||||
provider: 'finicity',
|
||||
categoryProvider: FinicityUtil.getAccountCategory(finicityAccount),
|
||||
subcategoryProvider: finicityAccount.type,
|
||||
name: finicityAccount.name,
|
||||
mask: finicityAccount.accountNumberDisplay,
|
||||
finicityType: finicityAccount.type,
|
||||
finicityDetail: finicityAccount.detail,
|
||||
...FinicityUtil.getAccountBalanceData(finicityAccount, classification),
|
||||
},
|
||||
update: {
|
||||
type: FinicityUtil.getType(finicityAccount),
|
||||
categoryProvider: FinicityUtil.getAccountCategory(finicityAccount),
|
||||
subcategoryProvider: finicityAccount.type,
|
||||
finicityType: finicityAccount.type,
|
||||
finicityDetail: finicityAccount.detail,
|
||||
..._.omit(
|
||||
FinicityUtil.getAccountBalanceData(finicityAccount, classification),
|
||||
['currentBalanceStrategy', 'availableBalanceStrategy']
|
||||
),
|
||||
},
|
||||
})
|
||||
}),
|
||||
// any accounts that are no longer in Finicity should be marked inactive
|
||||
this.prisma.account.updateMany({
|
||||
where: {
|
||||
accountConnectionId: connection.id,
|
||||
AND: [
|
||||
{ finicityAccountId: { not: null } },
|
||||
{ finicityAccountId: { notIn: accounts.map((a) => a.id) } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
isActive: false,
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
private _loadPositions(connection: Connection, { positions }: Pick<FinicityData, 'positions'>) {
|
||||
const securities = _(positions)
|
||||
.map((p) => p.security)
|
||||
.uniqBy((s) => getSecurityId(s))
|
||||
.value()
|
||||
|
||||
const securitiesWithPrices = securities.filter((s) => s.currentPrice != null)
|
||||
|
||||
return [
|
||||
...(securities.length > 0
|
||||
? [
|
||||
// upsert securities
|
||||
this.prisma.$executeRaw`
|
||||
INSERT INTO security (finicity_security_id, finicity_security_id_type, name, symbol, finicity_type, finicity_asset_class, finicity_fi_asset_class)
|
||||
VALUES
|
||||
${Prisma.join(
|
||||
securities.map(
|
||||
({
|
||||
securityId,
|
||||
securityIdType,
|
||||
securityName,
|
||||
symbol,
|
||||
type,
|
||||
assetClass,
|
||||
fiAssetClass,
|
||||
}) =>
|
||||
Prisma.sql`(
|
||||
${securityId},
|
||||
${securityIdType},
|
||||
${securityName},
|
||||
${symbol},
|
||||
${type},
|
||||
${assetClass},
|
||||
${fiAssetClass}
|
||||
)`
|
||||
)
|
||||
)}
|
||||
ON CONFLICT (finicity_security_id, finicity_security_id_type) DO UPDATE
|
||||
SET
|
||||
name = EXCLUDED.name,
|
||||
symbol = EXCLUDED.symbol,
|
||||
finicity_type = EXCLUDED.finicity_type,
|
||||
finicity_asset_class = EXCLUDED.finicity_asset_class,
|
||||
finicity_fi_asset_class = EXCLUDED.finicity_fi_asset_class
|
||||
`,
|
||||
]
|
||||
: []),
|
||||
...(securitiesWithPrices.length > 0
|
||||
? [
|
||||
// upsert security prices
|
||||
this.prisma.$executeRaw`
|
||||
INSERT INTO security_pricing (security_id, date, price_close, source)
|
||||
VALUES
|
||||
${Prisma.join(
|
||||
securitiesWithPrices.map(
|
||||
({
|
||||
securityId,
|
||||
securityIdType,
|
||||
currentPrice,
|
||||
currentPriceDate,
|
||||
}) =>
|
||||
Prisma.sql`(
|
||||
(SELECT id FROM security WHERE finicity_security_id = ${securityId} AND finicity_security_id_type = ${securityIdType}),
|
||||
${
|
||||
currentPriceDate
|
||||
? DateTime.fromSeconds(currentPriceDate, {
|
||||
zone: 'utc',
|
||||
}).toISODate()
|
||||
: DateTime.now().toISODate()
|
||||
}::date,
|
||||
${currentPrice},
|
||||
'finicity'
|
||||
)`
|
||||
)
|
||||
)}
|
||||
ON CONFLICT DO NOTHING
|
||||
`,
|
||||
]
|
||||
: []),
|
||||
...(positions.length > 0
|
||||
? [
|
||||
// upsert holdings
|
||||
this.prisma.$executeRaw`
|
||||
INSERT INTO holding (finicity_position_id, account_id, security_id, value, quantity, cost_basis_provider, currency_code)
|
||||
VALUES
|
||||
${Prisma.join(
|
||||
// de-dupe positions in case Finicity returns duplicate account/security pairs (they do for test accounts)
|
||||
_.uniqBy(
|
||||
positions,
|
||||
(p) => `${p.accountId}.${getSecurityId(p.security)}`
|
||||
).map(
|
||||
({
|
||||
id,
|
||||
accountId,
|
||||
security: { securityId, securityIdType },
|
||||
units,
|
||||
quantity,
|
||||
marketValue,
|
||||
costBasis,
|
||||
}) =>
|
||||
Prisma.sql`(
|
||||
${id},
|
||||
(SELECT id FROM account WHERE account_connection_id = ${
|
||||
connection.id
|
||||
} AND finicity_account_id = ${accountId}),
|
||||
(SELECT id FROM security WHERE finicity_security_id = ${securityId} AND finicity_security_id_type = ${securityIdType}),
|
||||
${marketValue || 0},
|
||||
${units ?? quantity ?? 0},
|
||||
${costBasis},
|
||||
${'USD'}
|
||||
)`
|
||||
)
|
||||
)}
|
||||
ON CONFLICT (finicity_position_id) DO UPDATE
|
||||
SET
|
||||
account_id = EXCLUDED.account_id,
|
||||
security_id = EXCLUDED.security_id,
|
||||
value = EXCLUDED.value,
|
||||
quantity = EXCLUDED.quantity,
|
||||
cost_basis_provider = EXCLUDED.cost_basis_provider,
|
||||
currency_code = EXCLUDED.currency_code;
|
||||
`,
|
||||
]
|
||||
: []),
|
||||
// Any holdings that are no longer in Finicity should be deleted
|
||||
this.prisma.holding.deleteMany({
|
||||
where: {
|
||||
account: {
|
||||
accountConnectionId: connection.id,
|
||||
},
|
||||
AND: [
|
||||
{ finicityPositionId: { not: null } },
|
||||
{
|
||||
finicityPositionId: {
|
||||
notIn: positions
|
||||
.map((p) => p.id?.toString())
|
||||
.filter((id): id is string => id != null),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
private async _extractTransactions(
|
||||
customerId: string,
|
||||
accountIds: string[],
|
||||
dateRange: SharedType.DateRange<DateTime>
|
||||
) {
|
||||
const accountTransactions = await Promise.all(
|
||||
accountIds.map((accountId) =>
|
||||
SharedUtil.paginate({
|
||||
pageSize: 1000, // https://api-reference.finicity.com/#/rest/api-endpoints/transactions/get-customer-account-transactions
|
||||
fetchData: async (offset, count) => {
|
||||
const { transactions } = await SharedUtil.withRetry(
|
||||
() =>
|
||||
this.finicity.getAccountTransactions({
|
||||
customerId,
|
||||
accountId,
|
||||
fromDate: dateRange.start.toUnixInteger(),
|
||||
toDate: dateRange.end.toUnixInteger(),
|
||||
start: offset + 1, // finicity uses 1-based indexing
|
||||
limit: count,
|
||||
}),
|
||||
{
|
||||
maxRetries: 3,
|
||||
}
|
||||
)
|
||||
|
||||
return transactions
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return accountTransactions.flat()
|
||||
}
|
||||
|
||||
private _loadTransactions(
|
||||
connection: Connection,
|
||||
{
|
||||
transactions,
|
||||
transactionsDateRange,
|
||||
}: Pick<FinicityData, 'transactions' | 'transactionsDateRange'>
|
||||
) {
|
||||
if (!transactions.length) return []
|
||||
|
||||
const txnUpsertQueries = _.chunk(transactions, 1_000).map((chunk) => {
|
||||
return this.prisma.$executeRaw`
|
||||
INSERT INTO transaction (account_id, finicity_transaction_id, date, name, amount, pending, currency_code, merchant_name, finicity_type, finicity_categorization)
|
||||
VALUES
|
||||
${Prisma.join(
|
||||
chunk.map((finicityTransaction) => {
|
||||
const {
|
||||
id,
|
||||
accountId,
|
||||
description,
|
||||
memo,
|
||||
amount,
|
||||
status,
|
||||
type,
|
||||
categorization,
|
||||
transactionDate,
|
||||
postedDate,
|
||||
currencySymbol,
|
||||
} = finicityTransaction
|
||||
|
||||
return Prisma.sql`(
|
||||
(SELECT id FROM account WHERE account_connection_id = ${
|
||||
connection.id
|
||||
} AND finicity_account_id = ${accountId.toString()}),
|
||||
${id},
|
||||
${DateTime.fromSeconds(transactionDate ?? postedDate, {
|
||||
zone: 'utc',
|
||||
}).toISODate()}::date,
|
||||
${[description, memo].filter(Boolean).join(' ')},
|
||||
${DbUtil.toDecimal(-amount)},
|
||||
${status === 'pending'},
|
||||
${currencySymbol || 'USD'},
|
||||
${categorization?.normalizedPayeeName},
|
||||
${type},
|
||||
${categorization}
|
||||
)`
|
||||
})
|
||||
)}
|
||||
ON CONFLICT (finicity_transaction_id) DO UPDATE
|
||||
SET
|
||||
name = EXCLUDED.name,
|
||||
amount = EXCLUDED.amount,
|
||||
pending = EXCLUDED.pending,
|
||||
merchant_name = EXCLUDED.merchant_name,
|
||||
finicity_type = EXCLUDED.finicity_type,
|
||||
finicity_categorization = EXCLUDED.finicity_categorization;
|
||||
`
|
||||
})
|
||||
|
||||
return [
|
||||
// upsert transactions
|
||||
...txnUpsertQueries,
|
||||
// delete finicity-specific transactions that are no longer in finicity
|
||||
this.prisma.transaction.deleteMany({
|
||||
where: {
|
||||
account: {
|
||||
accountConnectionId: connection.id,
|
||||
},
|
||||
AND: [
|
||||
{ finicityTransactionId: { not: null } },
|
||||
{ finicityTransactionId: { notIn: transactions.map((t) => `${t.id}`) } },
|
||||
],
|
||||
date: {
|
||||
gte: transactionsDateRange.start.startOf('day').toJSDate(),
|
||||
lte: transactionsDateRange.end.endOf('day').toJSDate(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
private _loadInvestmentTransactions(
|
||||
connection: Connection,
|
||||
{
|
||||
investmentTransactions,
|
||||
investmentTransactionsDateRange,
|
||||
}: Pick<FinicityData, 'investmentTransactions' | 'investmentTransactionsDateRange'>
|
||||
) {
|
||||
if (!investmentTransactions.length) return []
|
||||
|
||||
const securities = _(investmentTransactions)
|
||||
.map((p) => p.security)
|
||||
.filter(SharedUtil.nonNull)
|
||||
.uniqBy((s) => getSecurityId(s))
|
||||
.value()
|
||||
|
||||
return [
|
||||
// upsert securities
|
||||
...(securities.length > 0
|
||||
? [
|
||||
this.prisma.$executeRaw`
|
||||
INSERT INTO security (finicity_security_id, finicity_security_id_type, symbol)
|
||||
VALUES
|
||||
${Prisma.join(
|
||||
securities.map((s) => {
|
||||
return Prisma.sql`(
|
||||
${s.securityId},
|
||||
${s.securityIdType},
|
||||
${s.symbol}
|
||||
)`
|
||||
})
|
||||
)}
|
||||
ON CONFLICT DO NOTHING;
|
||||
`,
|
||||
]
|
||||
: []),
|
||||
|
||||
// upsert investment transactions
|
||||
..._.chunk(investmentTransactions, 1_000).map((chunk) => {
|
||||
return this.prisma.$executeRaw`
|
||||
INSERT INTO investment_transaction (account_id, security_id, finicity_transaction_id, date, name, amount, fees, quantity, price, currency_code, finicity_investment_transaction_type)
|
||||
VALUES
|
||||
${Prisma.join(
|
||||
chunk.map((t) => {
|
||||
const {
|
||||
id,
|
||||
accountId,
|
||||
amount,
|
||||
feeAmount,
|
||||
description,
|
||||
memo,
|
||||
unitQuantity,
|
||||
unitPrice,
|
||||
transactionDate,
|
||||
postedDate,
|
||||
currencySymbol,
|
||||
investmentTransactionType,
|
||||
security,
|
||||
} = t
|
||||
|
||||
return Prisma.sql`(
|
||||
(SELECT id FROM account WHERE account_connection_id = ${
|
||||
connection.id
|
||||
} AND finicity_account_id = ${accountId.toString()}),
|
||||
${
|
||||
security
|
||||
? Prisma.sql`(SELECT id FROM security WHERE finicity_security_id = ${security.securityId} AND finicity_security_id_type = ${security.securityIdType})`
|
||||
: null
|
||||
},
|
||||
${id},
|
||||
${DateTime.fromSeconds(transactionDate ?? postedDate, {
|
||||
zone: 'utc',
|
||||
}).toISODate()}::date,
|
||||
${[description, memo].filter(Boolean).join(' ')},
|
||||
${DbUtil.toDecimal(-amount)},
|
||||
${DbUtil.toDecimal(feeAmount)},
|
||||
${DbUtil.toDecimal(unitQuantity ?? 0)},
|
||||
${DbUtil.toDecimal(unitPrice ?? 0)},
|
||||
${currencySymbol || 'USD'},
|
||||
${investmentTransactionType}
|
||||
)`
|
||||
})
|
||||
)}
|
||||
ON CONFLICT (finicity_transaction_id) DO UPDATE
|
||||
SET
|
||||
account_id = EXCLUDED.account_id,
|
||||
security_id = EXCLUDED.security_id,
|
||||
date = EXCLUDED.date,
|
||||
name = EXCLUDED.name,
|
||||
amount = EXCLUDED.amount,
|
||||
fees = EXCLUDED.fees,
|
||||
quantity = EXCLUDED.quantity,
|
||||
price = EXCLUDED.price,
|
||||
currency_code = EXCLUDED.currency_code,
|
||||
finicity_investment_transaction_type = EXCLUDED.finicity_investment_transaction_type;
|
||||
`
|
||||
}),
|
||||
|
||||
// delete finicity-specific investment transactions that are no longer in finicity
|
||||
this.prisma.investmentTransaction.deleteMany({
|
||||
where: {
|
||||
account: {
|
||||
accountConnectionId: connection.id,
|
||||
},
|
||||
AND: [
|
||||
{ finicityTransactionId: { not: null } },
|
||||
{
|
||||
finicityTransactionId: {
|
||||
notIn: investmentTransactions.map((t) => `${t.id}`),
|
||||
},
|
||||
},
|
||||
],
|
||||
date: {
|
||||
gte: investmentTransactionsDateRange.start.startOf('day').toJSDate(),
|
||||
lte: investmentTransactionsDateRange.end.endOf('day').toJSDate(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,281 +0,0 @@
|
|||
import type { Logger } from 'winston'
|
||||
import type { AccountConnection, PrismaClient, User } from '@prisma/client'
|
||||
import type { IETL, SyncConnectionOptions } from '@maybe-finance/server/shared'
|
||||
import type { FinicityApi, FinicityTypes } from '@maybe-finance/finicity-api'
|
||||
import type { IInstitutionProvider } from '../../institution'
|
||||
import type {
|
||||
AccountConnectionSyncEvent,
|
||||
IAccountConnectionProvider,
|
||||
} from '../../account-connection'
|
||||
import _ from 'lodash'
|
||||
import axios from 'axios'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { SharedUtil } from '@maybe-finance/shared'
|
||||
import { etl } from '@maybe-finance/server/shared'
|
||||
|
||||
export interface IFinicityConnect {
|
||||
generateConnectUrl(userId: User['id'], institutionId: string): Promise<{ link: string }>
|
||||
|
||||
generateFixConnectUrl(
|
||||
userId: User['id'],
|
||||
accountConnectionId: AccountConnection['id']
|
||||
): Promise<{ link: string }>
|
||||
}
|
||||
|
||||
export class FinicityService
|
||||
implements IFinicityConnect, IAccountConnectionProvider, IInstitutionProvider
|
||||
{
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly finicity: FinicityApi,
|
||||
private readonly etl: IETL<AccountConnection>,
|
||||
private readonly webhookUrl: string | Promise<string>,
|
||||
private readonly testMode: boolean
|
||||
) {}
|
||||
|
||||
async generateConnectUrl(userId: User['id'], institutionId: string) {
|
||||
const customerId = await this.getOrCreateCustomerId(userId)
|
||||
|
||||
this.logger.debug(
|
||||
`Generating Finicity connect URL with user=${userId} institution=${institutionId} customerId=${customerId}`
|
||||
)
|
||||
|
||||
const res = await this.finicity.generateLiteConnectUrl({
|
||||
customerId,
|
||||
institutionId,
|
||||
webhook: await this.webhookUrl,
|
||||
webhookContentType: 'application/json',
|
||||
})
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
async generateFixConnectUrl(userId: User['id'], accountConnectionId: AccountConnection['id']) {
|
||||
const accountConnection = await this.prisma.accountConnection.findUniqueOrThrow({
|
||||
where: { id: accountConnectionId },
|
||||
})
|
||||
|
||||
if (!accountConnection.finicityInstitutionLoginId) {
|
||||
throw new Error(
|
||||
`connection ${accountConnection.id} is missing finicityInstitutionLoginId`
|
||||
)
|
||||
}
|
||||
|
||||
const res = await this.finicity.generateFixConnectUrl({
|
||||
customerId: await this.getOrCreateCustomerId(userId),
|
||||
institutionLoginId: accountConnection.finicityInstitutionLoginId,
|
||||
webhook: await this.webhookUrl,
|
||||
webhookContentType: 'application/json',
|
||||
})
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
async sync(connection: AccountConnection, options?: SyncConnectionOptions): Promise<void> {
|
||||
if (options && options.type !== 'finicity') throw new Error('invalid sync options')
|
||||
|
||||
if (options?.initialSync) {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: connection.userId },
|
||||
})
|
||||
|
||||
if (!user.finicityCustomerId) {
|
||||
throw new Error(`user ${user.id} missing finicityCustomerId`)
|
||||
}
|
||||
|
||||
// refresh customer accounts
|
||||
try {
|
||||
this.logger.info(
|
||||
`refreshing customer accounts for customer: ${user.finicityCustomerId}`
|
||||
)
|
||||
const { accounts } = await this.finicity.refreshCustomerAccounts({
|
||||
customerId: user.finicityCustomerId,
|
||||
})
|
||||
|
||||
// no need to await this - this is fire-and-forget and shouldn't delay the sync process
|
||||
this.logger.info(
|
||||
`triggering load historic transactions for customer: ${
|
||||
user.finicityCustomerId
|
||||
} accounts: ${accounts.map((a) => a.id)}`
|
||||
)
|
||||
Promise.allSettled(
|
||||
accounts
|
||||
.filter(
|
||||
(a) =>
|
||||
a.institutionLoginId.toString() ===
|
||||
connection.finicityInstitutionLoginId
|
||||
)
|
||||
.map((account) =>
|
||||
this.finicity
|
||||
.loadHistoricTransactions({
|
||||
accountId: account.id,
|
||||
customerId: account.customerId,
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.warn(
|
||||
`error loading historic transactions for finicity account: ${account.id} customer: ${account.customerId}`,
|
||||
err
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
// gracefully handle error, this shouldn't prevent the sync process from continuing
|
||||
this.logger.error(`error refreshing customer accounts for initial sync`, err)
|
||||
}
|
||||
}
|
||||
|
||||
await etl(this.etl, connection)
|
||||
}
|
||||
|
||||
async onSyncEvent(connection: AccountConnection, event: AccountConnectionSyncEvent) {
|
||||
switch (event.type) {
|
||||
case 'success': {
|
||||
await this.prisma.accountConnection.update({
|
||||
where: { id: connection.id },
|
||||
data: {
|
||||
status: 'OK',
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'error': {
|
||||
const { error } = event
|
||||
|
||||
await this.prisma.accountConnection.update({
|
||||
where: { id: connection.id },
|
||||
data: {
|
||||
status: 'ERROR',
|
||||
finicityError:
|
||||
axios.isAxiosError(error) && error.response
|
||||
? _.pick(error.response, ['status', 'data'])
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async delete(connection: AccountConnection): Promise<void> {
|
||||
if (connection.finicityInstitutionLoginId) {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: connection.userId },
|
||||
select: {
|
||||
finicityCustomerId: true,
|
||||
accountConnections: {
|
||||
where: {
|
||||
id: { not: connection.id },
|
||||
finicityInstitutionLoginId: connection.finicityInstitutionLoginId,
|
||||
},
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ensure there are no other connections with the same `finicityInstitutionLoginId` before deleting the accounts from Finicity
|
||||
if (user.finicityCustomerId && !user.accountConnections.length) {
|
||||
try {
|
||||
await this.finicity.deleteCustomerAccountsByInstitutionLogin({
|
||||
customerId: user.finicityCustomerId,
|
||||
institutionLoginId: +connection.finicityInstitutionLoginId,
|
||||
})
|
||||
this.logger.info(
|
||||
`deleted finicity customer ${user.finicityCustomerId} accounts for institutionLoginId ${connection.finicityInstitutionLoginId}`
|
||||
)
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`error deleting finicity customer ${user.finicityCustomerId} accounts for institutionLoginId ${connection.finicityInstitutionLoginId}`,
|
||||
err
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`skipping delete for finicity customer ${user.finicityCustomerId} accounts for institutionLoginId ${connection.finicityInstitutionLoginId} (duplicate_connections: ${user.accountConnections.length})`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getInstitutions() {
|
||||
const finicityInstitutions = await SharedUtil.paginate({
|
||||
pageSize: 1000,
|
||||
fetchData: (offset, count) =>
|
||||
SharedUtil.withRetry(
|
||||
() =>
|
||||
this.finicity
|
||||
.getInstitutions({
|
||||
start: offset / count + 1,
|
||||
limit: count,
|
||||
})
|
||||
.then(({ institutions, found, displaying }) => {
|
||||
this.logger.debug(
|
||||
`paginated finicity fetch inst=${displaying} (total=${found} offset=${offset} count=${count})`
|
||||
)
|
||||
return institutions
|
||||
}),
|
||||
{
|
||||
maxRetries: 3,
|
||||
onError: (error, attempt) => {
|
||||
this.logger.error(
|
||||
`Finicity fetch institutions request failed attempt=${attempt} offset=${offset} count=${count}`,
|
||||
{ error }
|
||||
)
|
||||
return (
|
||||
!axios.isAxiosError(error) ||
|
||||
(error.response && error.response.status >= 500)
|
||||
)
|
||||
},
|
||||
}
|
||||
),
|
||||
})
|
||||
|
||||
return _.uniqBy(finicityInstitutions, (i) => i.id).map((finicityInstitution) => ({
|
||||
providerId: `${finicityInstitution.id}`,
|
||||
name: finicityInstitution.name || '',
|
||||
url: finicityInstitution.urlHomeApp
|
||||
? SharedUtil.normalizeUrl(finicityInstitution.urlHomeApp)
|
||||
: null,
|
||||
logoUrl: finicityInstitution.branding?.icon,
|
||||
primaryColor: finicityInstitution.branding?.primaryColor,
|
||||
oauth: finicityInstitution.oauthEnabled,
|
||||
data: finicityInstitution,
|
||||
}))
|
||||
}
|
||||
|
||||
private async getOrCreateCustomerId(
|
||||
userId: User['id']
|
||||
): Promise<FinicityTypes.AddCustomerResponse['id']> {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { id: true, finicityCustomerId: true },
|
||||
})
|
||||
|
||||
if (user.finicityCustomerId) {
|
||||
return user.finicityCustomerId
|
||||
}
|
||||
|
||||
// See https://api-reference.finicity.com/#/rest/api-endpoints/customer/add-customer
|
||||
const finicityUsername = uuid()
|
||||
|
||||
const { id: finicityCustomerId } = this.testMode
|
||||
? await this.finicity.addTestingCustomer({ username: finicityUsername })
|
||||
: await this.finicity.addCustomer({ username: finicityUsername })
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
finicityUsername,
|
||||
finicityCustomerId,
|
||||
},
|
||||
})
|
||||
|
||||
this.logger.info(
|
||||
`created finicity customer ${finicityCustomerId} for user ${userId} (testMode=${this.testMode})`
|
||||
)
|
||||
return finicityCustomerId
|
||||
}
|
||||
}
|
|
@ -1,144 +0,0 @@
|
|||
import type { Logger } from 'winston'
|
||||
import _ from 'lodash'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import type { FinicityApi, FinicityTypes } from '@maybe-finance/finicity-api'
|
||||
import type { IAccountConnectionService } from '../../account-connection'
|
||||
|
||||
export interface IFinicityWebhookHandler {
|
||||
handleWebhook(data: FinicityTypes.WebhookData): Promise<void>
|
||||
handleTxPushEvent(event: FinicityTypes.TxPushEvent): Promise<void>
|
||||
}
|
||||
|
||||
export class FinicityWebhookHandler implements IFinicityWebhookHandler {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly finicity: FinicityApi,
|
||||
private readonly accountConnectionService: IAccountConnectionService,
|
||||
private readonly txPushUrl: string | Promise<string>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Process Finicity Connect webhooks. These handlers should execute as quick as possible and
|
||||
* long-running operations should be performed in the background.
|
||||
*/
|
||||
async handleWebhook(data: FinicityTypes.WebhookData) {
|
||||
switch (data.eventType) {
|
||||
case 'added': {
|
||||
const { accounts, institutionId } = data.payload
|
||||
|
||||
const { customerId, institutionLoginId } = accounts[0]
|
||||
|
||||
const [user, providerInstitution] = await Promise.all([
|
||||
this.prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
finicityCustomerId: customerId,
|
||||
},
|
||||
}),
|
||||
this.prisma.providerInstitution.findUnique({
|
||||
where: {
|
||||
provider_providerId: {
|
||||
provider: 'FINICITY',
|
||||
providerId: institutionId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
institution: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const connection = await this.prisma.accountConnection.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name:
|
||||
providerInstitution?.institution?.name ||
|
||||
providerInstitution?.name ||
|
||||
'Institution',
|
||||
type: 'finicity',
|
||||
finicityInstitutionId: institutionId,
|
||||
finicityInstitutionLoginId: String(institutionLoginId),
|
||||
},
|
||||
})
|
||||
|
||||
await Promise.allSettled([
|
||||
// subscribe to TxPUSH
|
||||
...accounts.map(async (account) =>
|
||||
this.finicity.subscribeTxPush({
|
||||
accountId: account.id,
|
||||
customerId: account.customerId,
|
||||
callbackUrl: await this.txPushUrl,
|
||||
})
|
||||
),
|
||||
])
|
||||
|
||||
// sync
|
||||
await this.accountConnectionService.sync(connection.id, {
|
||||
type: 'finicity',
|
||||
initialSync: true,
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
default: {
|
||||
this.logger.warn('Unhandled Finicity webhook', { data })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleTxPushEvent(event: FinicityTypes.TxPushEvent) {
|
||||
switch (event.class) {
|
||||
case 'account': {
|
||||
const connections = await this.prisma.accountConnection.findMany({
|
||||
where: {
|
||||
accounts: {
|
||||
some: {
|
||||
finicityAccountId: {
|
||||
in: _.uniq(event.records.map((a) => String(a.id))),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
await Promise.allSettled(
|
||||
connections.map((connection) =>
|
||||
this.accountConnectionService.sync(connection.id)
|
||||
)
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'transaction': {
|
||||
const connections = await this.prisma.accountConnection.findMany({
|
||||
where: {
|
||||
accounts: {
|
||||
some: {
|
||||
finicityAccountId: {
|
||||
in: _.uniq(event.records.map((t) => String(t.accountId))),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
await Promise.allSettled(
|
||||
connections.map((connection) =>
|
||||
this.accountConnectionService.sync(connection.id)
|
||||
)
|
||||
)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
this.logger.warn(`unhandled Finicity TxPush event`, { event })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export * from './finicity.service'
|
||||
export * from './finicity.etl'
|
||||
export * from './finicity.webhook'
|
|
@ -1,5 +1,4 @@
|
|||
export * from './plaid'
|
||||
export * from './finicity'
|
||||
export * from './teller'
|
||||
export * from './vehicle'
|
||||
export * from './property'
|
||||
|
|
|
@ -482,7 +482,6 @@ export function getPolygonTicker({
|
|||
}
|
||||
}
|
||||
|
||||
// Finicity's `type` field isn't really helpful here, so we'll just use isOptionTicker
|
||||
if (MarketUtil.isOptionTicker(symbol)) {
|
||||
return new PolygonTicker('options', `O:${symbol}`)
|
||||
}
|
||||
|
|
|
@ -42,7 +42,6 @@ export type SyncConnectionOptions =
|
|||
type: 'plaid'
|
||||
products?: Array<'transactions' | 'investment-transactions' | 'holdings' | 'liabilities'>
|
||||
}
|
||||
| { type: 'finicity'; initialSync?: boolean }
|
||||
| { type: 'teller'; initialSync?: boolean }
|
||||
|
||||
export type SyncConnectionQueueJobData = {
|
||||
|
@ -72,7 +71,7 @@ export type SyncSecurityQueue = IQueue<SyncSecurityQueueJobData, 'sync-all-secur
|
|||
export type PurgeUserQueue = IQueue<{ userId: User['id'] }, 'purge-user'>
|
||||
export type SyncInstitutionQueue = IQueue<
|
||||
{},
|
||||
'sync-finicity-institutions' | 'sync-plaid-institutions' | 'sync-teller-institutions'
|
||||
'sync-plaid-institutions' | 'sync-teller-institutions'
|
||||
>
|
||||
export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>
|
||||
|
||||
|
|
|
@ -71,7 +71,6 @@ export class InMemoryQueueFactory implements IQueueFactory {
|
|||
private readonly ignoreJobNames: string[] = [
|
||||
'sync-all-securities',
|
||||
'sync-plaid-institutions',
|
||||
'sync-finicity-institutions',
|
||||
'trial-reminders',
|
||||
'send-email',
|
||||
]
|
||||
|
|
|
@ -1,121 +0,0 @@
|
|||
import type { Account, AccountCategory, AccountClassification, AccountType } from '@prisma/client'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { Duration } from 'luxon'
|
||||
import type { FinicityTypes } from '@maybe-finance/finicity-api'
|
||||
|
||||
type FinicityAccount = FinicityTypes.CustomerAccount
|
||||
|
||||
/**
|
||||
* Finicity delivers up to 180 days prior to account addition but doesn't provide a cutoff window
|
||||
*/
|
||||
export const FINICITY_WINDOW_MAX = Duration.fromObject({ years: 2 })
|
||||
|
||||
export function getType({ type }: Pick<FinicityAccount, 'type'>): AccountType {
|
||||
switch (type) {
|
||||
case 'investment':
|
||||
case 'investmentTaxDeferred':
|
||||
case 'brokerageAccount':
|
||||
case '401k':
|
||||
case '401a':
|
||||
case '403b':
|
||||
case '457':
|
||||
case '457plan':
|
||||
case '529':
|
||||
case '529plan':
|
||||
case 'ira':
|
||||
case 'simpleIRA':
|
||||
case 'sepIRA':
|
||||
case 'roth':
|
||||
case 'roth401k':
|
||||
case 'rollover':
|
||||
case 'ugma':
|
||||
case 'utma':
|
||||
case 'keogh':
|
||||
case 'employeeStockPurchasePlan':
|
||||
return 'INVESTMENT'
|
||||
case 'creditCard':
|
||||
return 'CREDIT'
|
||||
case 'lineOfCredit':
|
||||
case 'loan':
|
||||
case 'studentLoan':
|
||||
case 'studentLoanAccount':
|
||||
case 'studentLoanGroup':
|
||||
case 'mortgage':
|
||||
return 'LOAN'
|
||||
default:
|
||||
return 'DEPOSITORY'
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccountCategory({ type }: Pick<FinicityAccount, 'type'>): AccountCategory {
|
||||
switch (type) {
|
||||
case 'checking':
|
||||
case 'savings':
|
||||
case 'cd':
|
||||
case 'moneyMarket':
|
||||
return 'cash'
|
||||
case 'investment':
|
||||
case 'investmentTaxDeferred':
|
||||
case 'brokerageAccount':
|
||||
case '401k':
|
||||
case '401a':
|
||||
case '403b':
|
||||
case '457':
|
||||
case '457plan':
|
||||
case '529':
|
||||
case '529plan':
|
||||
case 'ira':
|
||||
case 'simpleIRA':
|
||||
case 'sepIRA':
|
||||
case 'roth':
|
||||
case 'roth401k':
|
||||
case 'rollover':
|
||||
case 'ugma':
|
||||
case 'utma':
|
||||
case 'keogh':
|
||||
case 'employeeStockPurchasePlan':
|
||||
return 'investment'
|
||||
case 'mortgage':
|
||||
case 'loan':
|
||||
case 'lineOfCredit':
|
||||
case 'studentLoan':
|
||||
case 'studentLoanAccount':
|
||||
case 'studentLoanGroup':
|
||||
return 'loan'
|
||||
case 'creditCard':
|
||||
return 'credit'
|
||||
case 'cryptocurrency':
|
||||
return 'crypto'
|
||||
default:
|
||||
return 'other'
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccountBalanceData(
|
||||
{ balance, currency, detail }: Pick<FinicityAccount, 'balance' | 'currency' | 'detail'>,
|
||||
classification: AccountClassification
|
||||
): Pick<
|
||||
Account,
|
||||
| 'currentBalanceProvider'
|
||||
| 'currentBalanceStrategy'
|
||||
| 'availableBalanceProvider'
|
||||
| 'availableBalanceStrategy'
|
||||
| 'currencyCode'
|
||||
> {
|
||||
// Flip balance values to positive for liabilities
|
||||
const sign = classification === 'liability' ? -1 : 1
|
||||
|
||||
return {
|
||||
currentBalanceProvider: new Prisma.Decimal(balance ? sign * balance : 0),
|
||||
currentBalanceStrategy: 'current',
|
||||
availableBalanceProvider: !detail
|
||||
? null
|
||||
: detail.availableBalanceAmount != null
|
||||
? new Prisma.Decimal(sign * detail.availableBalanceAmount)
|
||||
: detail.availableCashBalance != null
|
||||
? new Prisma.Decimal(sign * detail.availableCashBalance)
|
||||
: null,
|
||||
availableBalanceStrategy: 'available',
|
||||
currencyCode: currency,
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
export * as AuthUtil from './auth-utils'
|
||||
export * as DbUtil from './db-utils'
|
||||
export * as FinicityUtil from './finicity-utils'
|
||||
export * as PlaidUtil from './plaid-utils'
|
||||
export * as TellerUtil from './teller-utils'
|
||||
export * as ErrorUtil from './error-utils'
|
||||
|
|
|
@ -12,13 +12,7 @@ export type { Transaction }
|
|||
|
||||
export type TransactionEnriched = Omit<
|
||||
Transaction,
|
||||
| 'plaidTransactionId'
|
||||
| 'plaidCategory'
|
||||
| 'plaidCategoryId'
|
||||
| 'plaidPersonalFinanceCategory'
|
||||
| 'finicityTransactionId'
|
||||
| 'finicityType'
|
||||
| 'finicityCategorization'
|
||||
'plaidTransactionId' | 'plaidCategory' | 'plaidCategoryId' | 'plaidPersonalFinanceCategory'
|
||||
> & {
|
||||
type: TransactionType
|
||||
userId: User['id']
|
||||
|
|
|
@ -39,7 +39,6 @@
|
|||
"@casl/ability": "^6.3.2",
|
||||
"@casl/prisma": "^1.4.1",
|
||||
"@fast-csv/format": "^4.3.5",
|
||||
"@finicity/connect-web-sdk": "^1.0.0-rc.4",
|
||||
"@headlessui/react": "^1.7.2",
|
||||
"@hookform/resolvers": "^2.9.6",
|
||||
"@polygon.io/client-js": "^6.0.6",
|
||||
|
@ -154,7 +153,7 @@
|
|||
"smooth-scroll-into-view-if-needed": "^1.1.33",
|
||||
"stripe": "^10.17.0",
|
||||
"superjson": "^1.11.0",
|
||||
"tailwindcss": "3.2.4",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"teller-connect-react": "^0.1.0",
|
||||
"tslib": "^2.3.0",
|
||||
"uuid": "^9.0.0",
|
||||
|
@ -188,9 +187,8 @@
|
|||
"@storybook/manager-webpack5": "6.5.15",
|
||||
"@storybook/react": "6.5.15",
|
||||
"@svgr/webpack": "^6.1.2",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@tailwindcss/typography": "^0.5.8",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@testing-library/jest-dom": "^5.16.2",
|
||||
"@testing-library/react": "13.4.0",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "investment_transaction"
|
||||
RENAME COLUMN "category" TO "category_old";
|
||||
|
||||
DROP VIEW IF EXISTS holdings_enriched;
|
||||
|
||||
ALTER TABLE "investment_transaction"
|
||||
ADD COLUMN "category" "InvestmentTransactionCategory" NOT NULL GENERATED ALWAYS AS (
|
||||
CASE
|
||||
WHEN "plaid_type" = 'buy' THEN 'buy'::"InvestmentTransactionCategory"
|
||||
WHEN "plaid_type" = 'sell' THEN 'sell'::"InvestmentTransactionCategory"
|
||||
WHEN "plaid_subtype" IN ('dividend', 'qualified dividend', 'non-qualified dividend') THEN 'dividend'::"InvestmentTransactionCategory"
|
||||
WHEN "plaid_subtype" IN ('non-resident tax', 'tax', 'tax withheld') THEN 'tax'::"InvestmentTransactionCategory"
|
||||
WHEN "plaid_type" = 'fee' OR "plaid_subtype" IN ('account fee', 'legal fee', 'management fee', 'margin expense', 'transfer fee', 'trust fee') THEN 'fee'::"InvestmentTransactionCategory"
|
||||
WHEN "plaid_type" = 'cash' THEN 'transfer'::"InvestmentTransactionCategory"
|
||||
WHEN "plaid_type" = 'cancel' THEN 'cancel'::"InvestmentTransactionCategory"
|
||||
|
||||
ELSE 'other'::"InvestmentTransactionCategory"
|
||||
END
|
||||
) STORED;
|
||||
|
||||
CREATE OR REPLACE VIEW holdings_enriched AS (
|
||||
SELECT
|
||||
h.id,
|
||||
h.account_id,
|
||||
h.security_id,
|
||||
h.quantity,
|
||||
COALESCE(pricing_latest.price_close * h.quantity * COALESCE(s.shares_per_contract, 1), h.value) AS "value",
|
||||
COALESCE(h.cost_basis, tcb.cost_basis * h.quantity) AS "cost_basis",
|
||||
COALESCE(h.cost_basis / h.quantity / COALESCE(s.shares_per_contract, 1), tcb.cost_basis) AS "cost_basis_per_share",
|
||||
pricing_latest.price_close AS "price",
|
||||
pricing_prev.price_close AS "price_prev",
|
||||
h.excluded
|
||||
FROM
|
||||
holding h
|
||||
INNER JOIN security s ON s.id = h.security_id
|
||||
-- latest security pricing
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
price_close
|
||||
FROM
|
||||
security_pricing
|
||||
WHERE
|
||||
security_id = h.security_id
|
||||
ORDER BY
|
||||
date DESC
|
||||
LIMIT 1
|
||||
) pricing_latest ON true
|
||||
-- previous security pricing (for computing daily ∆)
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
price_close
|
||||
FROM
|
||||
security_pricing
|
||||
WHERE
|
||||
security_id = h.security_id
|
||||
ORDER BY
|
||||
date DESC
|
||||
LIMIT 1
|
||||
OFFSET 1
|
||||
) pricing_prev ON true
|
||||
-- calculate cost basis from transactions
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
it.account_id,
|
||||
it.security_id,
|
||||
SUM(it.quantity * it.price) / SUM(it.quantity) AS cost_basis
|
||||
FROM
|
||||
investment_transaction it
|
||||
WHERE
|
||||
it.plaid_type = 'buy'
|
||||
AND it.quantity > 0
|
||||
GROUP BY
|
||||
it.account_id,
|
||||
it.security_id
|
||||
) tcb ON tcb.account_id = h.account_id AND tcb.security_id = s.id
|
||||
);
|
||||
|
||||
ALTER TABLE "investment_transaction" DROP COLUMN "category_old";
|
|
@ -0,0 +1,91 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "transaction"
|
||||
RENAME COLUMN "category" TO "category_old";
|
||||
ALTER TABLE "transaction"
|
||||
RENAME COLUMN "category_user" TO "category_user_old";
|
||||
|
||||
DROP VIEW IF EXISTS transactions_enriched;
|
||||
|
||||
ALTER TABLE "transaction" ADD COLUMN "category_user" TEXT;
|
||||
|
||||
ALTER TABLE "transaction"
|
||||
ADD COLUMN "category" TEXT NOT NULL GENERATED ALWAYS AS (COALESCE(category_user,
|
||||
CASE
|
||||
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'INCOME'::text) THEN 'Income'::text
|
||||
WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = ANY (ARRAY['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text])) THEN 'Housing Payments'::text
|
||||
WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = 'LOAN_PAYMENTS_CAR_PAYMENT'::text) THEN 'Vehicle Payments'::text
|
||||
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'LOAN_PAYMENTS'::text) THEN 'Other Payments'::text
|
||||
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'HOME_IMPROVEMENT'::text) THEN 'Home Improvement'::text
|
||||
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'GENERAL_MERCHANDISE'::text) THEN 'Shopping'::text
|
||||
WHEN (((plaid_personal_finance_category ->> 'primary'::text) = 'RENT_AND_UTILITIES'::text) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'RENT_AND_UTILITIES_RENT'::text)) THEN 'Utilities'::text
|
||||
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'FOOD_AND_DRINK'::text) THEN 'Food and Drink'::text
|
||||
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRANSPORTATION'::text) THEN 'Transportation'::text
|
||||
WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRAVEL'::text) THEN 'Travel'::text
|
||||
WHEN (((plaid_personal_finance_category ->> 'primary'::text) = ANY (ARRAY['PERSONAL_CARE'::text, 'MEDICAL'::text])) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'MEDICAL_VETERINARY_SERVICES'::text)) THEN 'Health'::text
|
||||
WHEN (teller_category = 'income'::text) THEN 'Income'::text
|
||||
WHEN (teller_category = 'home'::text) THEN 'Home Improvement'::text
|
||||
WHEN (teller_category = ANY (ARRAY['phone'::text, 'utilities'::text])) THEN 'Utilities'::text
|
||||
WHEN (teller_category = ANY (ARRAY['dining'::text, 'bar'::text, 'groceries'::text])) THEN 'Food and Drink'::text
|
||||
WHEN (teller_category = ANY (ARRAY['clothing'::text, 'entertainment'::text, 'shopping'::text, 'electronics'::text, 'software'::text, 'sport'::text])) THEN 'Shopping'::text
|
||||
WHEN (teller_category = ANY (ARRAY['transportation'::text, 'fuel'::text])) THEN 'Transportation'::text
|
||||
WHEN (teller_category = ANY (ARRAY['accommodation'::text, 'transport'::text])) THEN 'Travel'::text
|
||||
WHEN (teller_category = 'health'::text) THEN 'Health'::text
|
||||
WHEN (teller_category = ANY (ARRAY['loan'::text, 'tax'::text, 'insurance'::text, 'office'::text])) THEN 'Other Payments'::text
|
||||
ELSE 'Other'::text
|
||||
END)) STORED;
|
||||
|
||||
CREATE OR REPLACE VIEW transactions_enriched AS (
|
||||
SELECT
|
||||
t.id,
|
||||
t.created_at as "createdAt",
|
||||
t.updated_at as "updatedAt",
|
||||
t.name,
|
||||
t.account_id as "accountId",
|
||||
t.date,
|
||||
t.flow,
|
||||
COALESCE(
|
||||
t.type_user,
|
||||
CASE
|
||||
-- no matching transaction
|
||||
WHEN t.match_id IS NULL THEN (
|
||||
CASE
|
||||
t.flow
|
||||
WHEN 'INFLOW' THEN (
|
||||
CASE
|
||||
a.classification
|
||||
WHEN 'asset' THEN 'INCOME' :: "TransactionType"
|
||||
WHEN 'liability' THEN 'PAYMENT' :: "TransactionType"
|
||||
END
|
||||
)
|
||||
WHEN 'OUTFLOW' THEN 'EXPENSE' :: "TransactionType"
|
||||
END
|
||||
) -- has matching transaction
|
||||
ELSE (
|
||||
CASE
|
||||
a.classification
|
||||
WHEN 'asset' THEN 'TRANSFER' :: "TransactionType"
|
||||
WHEN 'liability' THEN 'PAYMENT' :: "TransactionType"
|
||||
END
|
||||
)
|
||||
END
|
||||
) AS "type",
|
||||
t.type_user as "typeUser",
|
||||
t.amount,
|
||||
t.currency_code as "currencyCode",
|
||||
t.pending,
|
||||
t.merchant_name as "merchantName",
|
||||
t.category,
|
||||
t.category_user as "categoryUser",
|
||||
t.excluded,
|
||||
t.match_id as "matchId",
|
||||
COALESCE(ac.user_id, a.user_id) as "userId",
|
||||
a.classification as "accountClassification",
|
||||
a.type as "accountType"
|
||||
FROM
|
||||
transaction t
|
||||
inner join account a on a.id = t.account_id
|
||||
left join account_connection ac on a.account_connection_id = ac.id
|
||||
);
|
||||
|
||||
ALTER TABLE "transaction" DROP COLUMN "category_old";
|
||||
ALTER TABLE "transaction" DROP COLUMN "category_user_old";
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- The values [finicity] on the enum `AccountConnectionType` will be removed. If these variants are still used in the database, this will fail.
|
||||
- The values [finicity] on the enum `AccountProvider` will be removed. If these variants are still used in the database, this will fail.
|
||||
- The values [FINICITY] on the enum `Provider` will be removed. If these variants are still used in the database, this will fail.
|
||||
- You are about to drop the column `finicity_account_id` on the `account` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_detail` on the `account` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_type` on the `account` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_error` on the `account_connection` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_institution_id` on the `account_connection` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_institution_login_id` on the `account_connection` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_position_id` on the `holding` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_investment_transaction_type` on the `investment_transaction` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_transaction_id` on the `investment_transaction` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_asset_class` on the `security` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_fi_asset_class` on the `security` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_security_id` on the `security` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_security_id_type` on the `security` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_type` on the `security` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_categorization` on the `transaction` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_transaction_id` on the `transaction` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_type` on the `transaction` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_customer_id` on the `user` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `finicity_username` on the `user` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterEnum
|
||||
BEGIN;
|
||||
CREATE TYPE "AccountConnectionType_new" AS ENUM ('plaid', 'teller');
|
||||
ALTER TABLE "account_connection" ALTER COLUMN "type" TYPE "AccountConnectionType_new" USING ("type"::text::"AccountConnectionType_new");
|
||||
ALTER TYPE "AccountConnectionType" RENAME TO "AccountConnectionType_old";
|
||||
ALTER TYPE "AccountConnectionType_new" RENAME TO "AccountConnectionType";
|
||||
DROP TYPE "AccountConnectionType_old";
|
||||
COMMIT;
|
||||
|
||||
-- AlterEnum
|
||||
BEGIN;
|
||||
CREATE TYPE "AccountProvider_new" AS ENUM ('user', 'plaid', 'teller');
|
||||
ALTER TABLE "account" ALTER COLUMN "provider" TYPE "AccountProvider_new" USING ("provider"::text::"AccountProvider_new");
|
||||
ALTER TYPE "AccountProvider" RENAME TO "AccountProvider_old";
|
||||
ALTER TYPE "AccountProvider_new" RENAME TO "AccountProvider";
|
||||
DROP TYPE "AccountProvider_old";
|
||||
COMMIT;
|
||||
|
||||
-- AlterEnum
|
||||
BEGIN;
|
||||
CREATE TYPE "Provider_new" AS ENUM ('PLAID', 'TELLER');
|
||||
ALTER TABLE "provider_institution" ALTER COLUMN "provider" TYPE "Provider_new" USING ("provider"::text::"Provider_new");
|
||||
ALTER TYPE "Provider" RENAME TO "Provider_old";
|
||||
ALTER TYPE "Provider_new" RENAME TO "Provider";
|
||||
DROP TYPE "Provider_old";
|
||||
COMMIT;
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "account_account_connection_id_finicity_account_id_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "holding_finicity_position_id_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "investment_transaction_finicity_transaction_id_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "security_finicity_security_id_finicity_security_id_type_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "transaction_finicity_transaction_id_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "user_finicity_customer_id_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "user_finicity_username_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "account" DROP COLUMN "finicity_account_id",
|
||||
DROP COLUMN "finicity_detail",
|
||||
DROP COLUMN "finicity_type";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "account_connection" DROP COLUMN "finicity_error",
|
||||
DROP COLUMN "finicity_institution_id",
|
||||
DROP COLUMN "finicity_institution_login_id";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "holding" DROP COLUMN "finicity_position_id";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "investment_transaction" DROP COLUMN "finicity_investment_transaction_type",
|
||||
DROP COLUMN "finicity_transaction_id";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "security" DROP COLUMN "finicity_asset_class",
|
||||
DROP COLUMN "finicity_fi_asset_class",
|
||||
DROP COLUMN "finicity_security_id",
|
||||
DROP COLUMN "finicity_security_id_type",
|
||||
DROP COLUMN "finicity_type";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "transaction" DROP COLUMN "finicity_categorization",
|
||||
DROP COLUMN "finicity_transaction_id",
|
||||
DROP COLUMN "finicity_type";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user" DROP COLUMN "finicity_customer_id",
|
||||
DROP COLUMN "finicity_username";
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Institution, PrismaClient, Provider } from '@prisma/client'
|
||||
import bcrypt from 'bcrypt'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
|
@ -12,22 +13,32 @@ 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' }],
|
||||
},
|
||||
]
|
||||
|
||||
const hashedPassword = await bcrypt.hash('TestPassword123', 10)
|
||||
|
||||
await prisma.$transaction([
|
||||
// create testing auth user
|
||||
prisma.authUser.upsert({
|
||||
where: {
|
||||
id: 'test_ec3ee8a4-fa01-4f11-8ac5-9c49dd7fbae4',
|
||||
},
|
||||
create: {
|
||||
id: 'test_ec3ee8a4-fa01-4f11-8ac5-9c49dd7fbae4',
|
||||
firstName: 'James',
|
||||
lastName: 'Bond',
|
||||
email: 'bond@007.com',
|
||||
password: hashedPassword,
|
||||
},
|
||||
update: {},
|
||||
}),
|
||||
// create institution linked to provider institutions
|
||||
...institutions.map(({ id, name, providers }) =>
|
||||
prisma.institution.upsert({
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1 +0,0 @@
|
|||
export * from './finicityTestData'
|
|
@ -1,3 +1,2 @@
|
|||
export * as FinicityTestData from './finicity'
|
||||
export * as PlaidTestData from './plaid'
|
||||
export * as PolygonTestData from './polygon'
|
||||
|
|
|
@ -19,7 +19,6 @@
|
|||
"@maybe-finance/client/features": ["libs/client/features/src/index.ts"],
|
||||
"@maybe-finance/client/shared": ["libs/client/shared/src/index.ts"],
|
||||
"@maybe-finance/design-system": ["libs/design-system/src/index.ts"],
|
||||
"@maybe-finance/finicity-api": ["libs/finicity-api/src/index.ts"],
|
||||
"@maybe-finance/server/features": ["libs/server/features/src/index.ts"],
|
||||
"@maybe-finance/server/shared": ["libs/server/shared/src/index.ts"],
|
||||
"@maybe-finance/shared": ["libs/shared/src/index.ts"],
|
||||
|
|
|
@ -230,30 +230,6 @@
|
|||
"tags": ["scope:app"],
|
||||
"implicitDependencies": ["client", "server", "workers"]
|
||||
},
|
||||
"finicity-api": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "libs/finicity-api",
|
||||
"sourceRoot": "libs/finicity-api/src",
|
||||
"projectType": "library",
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/finicity-api/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/finicity-api"],
|
||||
"options": {
|
||||
"jestConfig": "libs/finicity-api/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["scope:shared"]
|
||||
},
|
||||
"server": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/server",
|
||||
|
|
409
yarn.lock
409
yarn.lock
|
@ -2,6 +2,11 @@
|
|||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@alloc/quick-lru@^5.2.0":
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30"
|
||||
integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
|
||||
|
||||
"@ampproject/remapping@^2.1.0":
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.1.2.tgz#4edca94973ded9630d20101cd8559cedb8d8bd34"
|
||||
|
@ -1760,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"
|
||||
|
@ -1811,6 +1811,18 @@
|
|||
resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11"
|
||||
integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==
|
||||
|
||||
"@isaacs/cliui@^8.0.2":
|
||||
version "8.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"
|
||||
integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==
|
||||
dependencies:
|
||||
string-width "^5.1.2"
|
||||
string-width-cjs "npm:string-width@^4.2.0"
|
||||
strip-ansi "^7.0.1"
|
||||
strip-ansi-cjs "npm:strip-ansi@^6.0.1"
|
||||
wrap-ansi "^8.1.0"
|
||||
wrap-ansi-cjs "npm:wrap-ansi@^7.0.0"
|
||||
|
||||
"@istanbuljs/load-nyc-config@^1.0.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
|
||||
|
@ -2736,6 +2748,11 @@
|
|||
dependencies:
|
||||
esquery "^1.0.1"
|
||||
|
||||
"@pkgjs/parseargs@^0.11.0":
|
||||
version "0.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
|
||||
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
|
||||
|
||||
"@pkgr/utils@^2.3.1":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@pkgr/utils/-/utils-2.3.1.tgz#0a9b06ffddee364d6642b3cd562ca76f55b34a03"
|
||||
|
@ -4055,22 +4072,17 @@
|
|||
dependencies:
|
||||
defer-to-connect "^2.0.0"
|
||||
|
||||
"@tailwindcss/forms@^0.5.3":
|
||||
version "0.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.3.tgz#e4d7989686cbcaf416c53f1523df5225332a86e7"
|
||||
integrity sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==
|
||||
"@tailwindcss/forms@^0.5.7":
|
||||
version "0.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.7.tgz#db5421f062a757b5f828bc9286ba626c6685e821"
|
||||
integrity sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==
|
||||
dependencies:
|
||||
mini-svg-data-uri "^1.2.3"
|
||||
|
||||
"@tailwindcss/line-clamp@^0.4.2":
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/line-clamp/-/line-clamp-0.4.2.tgz#f353c5a8ab2c939c6267ac5b907f012e5ee130f9"
|
||||
integrity sha512-HFzAQuqYCjyy/SX9sLGB1lroPzmcnWv1FHkIpmypte10hptf4oPUfucryMKovZh2u0uiS9U5Ty3GghWfEJGwVw==
|
||||
|
||||
"@tailwindcss/typography@^0.5.8":
|
||||
version "0.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.8.tgz#8fb31db5ab0590be6dfa062b1535ac86ad9d12bf"
|
||||
integrity sha512-xGQEp8KXN8Sd8m6R4xYmwxghmswrd0cPnNI2Lc6fmrC3OojysTBJJGSIVwPV56q4t6THFUK3HJ0EaWwpglSxWw==
|
||||
"@tailwindcss/typography@^0.5.10":
|
||||
version "0.5.10"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.10.tgz#2abde4c6d5c797ab49cf47610830a301de4c1e0a"
|
||||
integrity sha512-Pe8BuPJQJd3FfRnm6H0ulKIGoMEQS+Vq01R6M5aCrFB/ccR/shT+0kXLjouGC1gFLm9hopTFN+DMP0pfwRWzPw==
|
||||
dependencies:
|
||||
lodash.castarray "^4.4.0"
|
||||
lodash.isplainobject "^4.0.6"
|
||||
|
@ -5908,16 +5920,7 @@ acorn-jsx@^5.3.1, acorn-jsx@^5.3.2:
|
|||
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
|
||||
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
|
||||
|
||||
acorn-node@^1.8.2:
|
||||
version "1.8.2"
|
||||
resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8"
|
||||
integrity sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==
|
||||
dependencies:
|
||||
acorn "^7.0.0"
|
||||
acorn-walk "^7.0.0"
|
||||
xtend "^4.0.2"
|
||||
|
||||
acorn-walk@^7.0.0, acorn-walk@^7.1.1, acorn-walk@^7.2.0:
|
||||
acorn-walk@^7.1.1, acorn-walk@^7.2.0:
|
||||
version "7.2.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
|
||||
integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==
|
||||
|
@ -5932,7 +5935,7 @@ acorn@^6.4.1:
|
|||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6"
|
||||
integrity sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==
|
||||
|
||||
acorn@^7.0.0, acorn@^7.1.1, acorn@^7.4.1:
|
||||
acorn@^7.1.1, acorn@^7.4.1:
|
||||
version "7.4.1"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
|
||||
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
|
||||
|
@ -6112,6 +6115,11 @@ ansi-styles@^6.0.0:
|
|||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.1.0.tgz#87313c102b8118abd57371afab34618bf7350ed3"
|
||||
integrity sha512-VbqNsoz55SYGczauuup0MFUyXNQviSpFTj1RQtFzmQLk18qbVSpTFFGMT293rmDaQuKCT6InmbuEyUne4mTuxQ==
|
||||
|
||||
ansi-styles@^6.1.0:
|
||||
version "6.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
|
||||
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
|
||||
|
||||
ansi-to-html@^0.6.11:
|
||||
version "0.6.15"
|
||||
resolved "https://registry.yarnpkg.com/ansi-to-html/-/ansi-to-html-0.6.15.tgz#ac6ad4798a00f6aa045535d7f6a9cb9294eebea7"
|
||||
|
@ -6119,6 +6127,11 @@ ansi-to-html@^0.6.11:
|
|||
dependencies:
|
||||
entities "^2.0.0"
|
||||
|
||||
any-promise@^1.0.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
|
||||
integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==
|
||||
|
||||
anymatch@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb"
|
||||
|
@ -6972,6 +6985,13 @@ brace-expansion@^1.1.7:
|
|||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
|
||||
brace-expansion@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae"
|
||||
integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==
|
||||
dependencies:
|
||||
balanced-match "^1.0.0"
|
||||
|
||||
braces@^2.3.1, braces@^2.3.2:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729"
|
||||
|
@ -7743,7 +7763,7 @@ color-name@1.1.3:
|
|||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
|
||||
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
|
||||
|
||||
color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
|
||||
color-name@^1.0.0, color-name@~1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||
|
@ -7814,7 +7834,7 @@ commander@^2.19.0, commander@^2.20.0:
|
|||
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
||||
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
||||
|
||||
commander@^4.1.1:
|
||||
commander@^4.0.0, commander@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
|
||||
integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
|
||||
|
@ -8757,11 +8777,6 @@ define-property@^2.0.2:
|
|||
is-descriptor "^1.0.2"
|
||||
isobject "^3.0.1"
|
||||
|
||||
defined@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693"
|
||||
integrity sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=
|
||||
|
||||
delayed-stream@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
|
||||
|
@ -8842,15 +8857,6 @@ detect-port@^1.3.0:
|
|||
address "^1.0.1"
|
||||
debug "^2.6.0"
|
||||
|
||||
detective@^5.2.1:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.1.tgz#6af01eeda11015acb0e73f933242b70f24f91034"
|
||||
integrity sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==
|
||||
dependencies:
|
||||
acorn-node "^1.8.2"
|
||||
defined "^1.0.0"
|
||||
minimist "^1.2.6"
|
||||
|
||||
didyoumean@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
|
||||
|
@ -10148,6 +10154,17 @@ fast-glob@^3.2.11, fast-glob@^3.2.12:
|
|||
merge2 "^1.3.0"
|
||||
micromatch "^4.0.4"
|
||||
|
||||
fast-glob@^3.3.0:
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129"
|
||||
integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==
|
||||
dependencies:
|
||||
"@nodelib/fs.stat" "^2.0.2"
|
||||
"@nodelib/fs.walk" "^1.2.3"
|
||||
glob-parent "^5.1.2"
|
||||
merge2 "^1.3.0"
|
||||
micromatch "^4.0.4"
|
||||
|
||||
fast-json-parse@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/fast-json-parse/-/fast-json-parse-1.0.3.tgz#43e5c61ee4efa9265633046b770fb682a7577c4d"
|
||||
|
@ -10428,6 +10445,14 @@ foreground-child@^2.0.0:
|
|||
cross-spawn "^7.0.0"
|
||||
signal-exit "^3.0.2"
|
||||
|
||||
foreground-child@^3.1.0:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d"
|
||||
integrity sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==
|
||||
dependencies:
|
||||
cross-spawn "^7.0.0"
|
||||
signal-exit "^4.0.1"
|
||||
|
||||
forever-agent@~0.6.1:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
|
||||
|
@ -10653,6 +10678,11 @@ function-bind@^1.1.1:
|
|||
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
|
||||
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
|
||||
|
||||
function-bind@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
|
||||
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
|
||||
|
||||
function.prototype.name@^1.1.0, function.prototype.name@^1.1.5:
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621"
|
||||
|
@ -10867,6 +10897,17 @@ glob@7.1.7:
|
|||
once "^1.3.0"
|
||||
path-is-absolute "^1.0.0"
|
||||
|
||||
glob@^10.3.10:
|
||||
version "10.3.10"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.10.tgz#0351ebb809fd187fe421ab96af83d3a70715df4b"
|
||||
integrity sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==
|
||||
dependencies:
|
||||
foreground-child "^3.1.0"
|
||||
jackspeak "^2.3.5"
|
||||
minimatch "^9.0.1"
|
||||
minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
path-scurry "^1.10.1"
|
||||
|
||||
glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
|
||||
version "7.2.3"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
|
||||
|
@ -11167,6 +11208,13 @@ hash.js@^1.0.0, hash.js@^1.0.3:
|
|||
inherits "^2.0.3"
|
||||
minimalistic-assert "^1.0.1"
|
||||
|
||||
hasown@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.0.tgz#f4c513d454a57b7c7e1650778de226b11700546c"
|
||||
integrity sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==
|
||||
dependencies:
|
||||
function-bind "^1.1.2"
|
||||
|
||||
hast-to-hyperscript@^9.0.0:
|
||||
version "9.0.1"
|
||||
resolved "https://registry.yarnpkg.com/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz#9b67fd188e4c81e8ad66f803855334173920218d"
|
||||
|
@ -11880,6 +11928,13 @@ is-core-module@^2.10.0:
|
|||
dependencies:
|
||||
has "^1.0.3"
|
||||
|
||||
is-core-module@^2.13.0:
|
||||
version "2.13.1"
|
||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384"
|
||||
integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==
|
||||
dependencies:
|
||||
hasown "^2.0.0"
|
||||
|
||||
is-core-module@^2.2.0:
|
||||
version "2.8.1"
|
||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211"
|
||||
|
@ -12327,6 +12382,15 @@ iterate-value@^1.0.2:
|
|||
es-get-iterator "^1.0.2"
|
||||
iterate-iterator "^1.0.1"
|
||||
|
||||
jackspeak@^2.3.5:
|
||||
version "2.3.6"
|
||||
resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8"
|
||||
integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==
|
||||
dependencies:
|
||||
"@isaacs/cliui" "^8.0.2"
|
||||
optionalDependencies:
|
||||
"@pkgjs/parseargs" "^0.11.0"
|
||||
|
||||
jake@^10.8.5:
|
||||
version "10.8.5"
|
||||
resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.5.tgz#f2183d2c59382cb274226034543b9c03b8164c46"
|
||||
|
@ -12884,6 +12948,11 @@ jest@28.1.1:
|
|||
import-local "^3.0.2"
|
||||
jest-cli "^28.1.1"
|
||||
|
||||
jiti@^1.19.1:
|
||||
version "1.21.0"
|
||||
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.0.tgz#7c97f8fe045724e136a397f7340475244156105d"
|
||||
integrity sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==
|
||||
|
||||
joi@^17.6.0:
|
||||
version "17.6.0"
|
||||
resolved "https://registry.yarnpkg.com/joi/-/joi-17.6.0.tgz#0bb54f2f006c09a96e75ce687957bd04290054b2"
|
||||
|
@ -13310,10 +13379,15 @@ lilconfig@2.0.4, lilconfig@^2.0.3, lilconfig@^2.0.4:
|
|||
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.4.tgz#f4507d043d7058b380b6a8f5cb7bcd4b34cee082"
|
||||
integrity sha512-bfTIN7lEsiooCocSISTWXkiWJkRqtL9wYtYy+8EK3Y41qh3mpwPU0ycTOgjdY9ErwXCc8QyrQp82bdL0Xkm9yA==
|
||||
|
||||
lilconfig@^2.0.5, lilconfig@^2.0.6:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4"
|
||||
integrity sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==
|
||||
lilconfig@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52"
|
||||
integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==
|
||||
|
||||
lilconfig@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.0.0.tgz#f8067feb033b5b74dab4602a5f5029420be749bc"
|
||||
integrity sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==
|
||||
|
||||
limiter@^1.1.5:
|
||||
version "1.1.5"
|
||||
|
@ -13681,6 +13755,11 @@ lru-cache@^6.0.0:
|
|||
dependencies:
|
||||
yallist "^4.0.0"
|
||||
|
||||
"lru-cache@^9.1.1 || ^10.0.0":
|
||||
version "10.1.0"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.1.0.tgz#2098d41c2dc56500e6c88584aa656c84de7d0484"
|
||||
integrity sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==
|
||||
|
||||
lru-cache@~4.0.0:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.0.2.tgz#1d17679c069cda5d040991a09dbc2c0db377e55e"
|
||||
|
@ -14116,6 +14195,13 @@ minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2:
|
|||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimatch@^9.0.1:
|
||||
version "9.0.3"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825"
|
||||
integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
|
||||
minimist@^1.1.1:
|
||||
version "1.2.5"
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
|
||||
|
@ -14159,6 +14245,11 @@ minipass@^5.0.0:
|
|||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d"
|
||||
integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==
|
||||
|
||||
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0":
|
||||
version "7.0.4"
|
||||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c"
|
||||
integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==
|
||||
|
||||
minizlib@^2.1.1:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
|
||||
|
@ -14294,6 +14385,15 @@ multicast-dns@^7.2.5:
|
|||
dns-packet "^5.2.2"
|
||||
thunky "^1.0.2"
|
||||
|
||||
mz@^2.7.0:
|
||||
version "2.7.0"
|
||||
resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
|
||||
integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
|
||||
dependencies:
|
||||
any-promise "^1.0.0"
|
||||
object-assign "^4.0.1"
|
||||
thenify-all "^1.0.0"
|
||||
|
||||
namespace-emitter@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/namespace-emitter/-/namespace-emitter-2.0.1.tgz#978d51361c61313b4e6b8cf6f3853d08dfa2b17c"
|
||||
|
@ -14319,6 +14419,11 @@ nanoid@^3.3.1, nanoid@^3.3.4:
|
|||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
|
||||
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
|
||||
|
||||
nanoid@^3.3.7:
|
||||
version "3.3.7"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
|
||||
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
|
||||
|
||||
nanoid@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.0.tgz#6e144dee117609232c3f415c34b0e550e64999a5"
|
||||
|
@ -15305,6 +15410,14 @@ path-parse@^1.0.6, path-parse@^1.0.7:
|
|||
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
|
||||
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
|
||||
|
||||
path-scurry@^1.10.1:
|
||||
version "1.10.1"
|
||||
resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.10.1.tgz#9ba6bf5aa8500fe9fd67df4f0d9483b2b0bfc698"
|
||||
integrity sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==
|
||||
dependencies:
|
||||
lru-cache "^9.1.1 || ^10.0.0"
|
||||
minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
|
||||
path-to-regexp@0.1.7:
|
||||
version "0.1.7"
|
||||
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
|
||||
|
@ -15585,7 +15698,16 @@ postcss-flexbugs-fixes@^4.2.1:
|
|||
dependencies:
|
||||
postcss "^7.0.26"
|
||||
|
||||
postcss-import@^14.1.0, postcss-import@~14.1.0:
|
||||
postcss-import@^15.1.0:
|
||||
version "15.1.0"
|
||||
resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70"
|
||||
integrity sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==
|
||||
dependencies:
|
||||
postcss-value-parser "^4.0.0"
|
||||
read-cache "^1.0.0"
|
||||
resolve "^1.1.7"
|
||||
|
||||
postcss-import@~14.1.0:
|
||||
version "14.1.0"
|
||||
resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-14.1.0.tgz#a7333ffe32f0b8795303ee9e40215dac922781f0"
|
||||
integrity sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==
|
||||
|
@ -15594,10 +15716,10 @@ postcss-import@^14.1.0, postcss-import@~14.1.0:
|
|||
read-cache "^1.0.0"
|
||||
resolve "^1.1.7"
|
||||
|
||||
postcss-js@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.0.tgz#31db79889531b80dc7bc9b0ad283e418dce0ac00"
|
||||
integrity sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==
|
||||
postcss-js@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.1.tgz#61598186f3703bab052f1c4f7d805f3991bee9d2"
|
||||
integrity sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==
|
||||
dependencies:
|
||||
camelcase-css "^2.0.1"
|
||||
|
||||
|
@ -15609,13 +15731,13 @@ postcss-load-config@^3.0.0:
|
|||
lilconfig "^2.0.4"
|
||||
yaml "^1.10.2"
|
||||
|
||||
postcss-load-config@^3.1.4:
|
||||
version "3.1.4"
|
||||
resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-3.1.4.tgz#1ab2571faf84bb078877e1d07905eabe9ebda855"
|
||||
integrity sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==
|
||||
postcss-load-config@^4.0.1:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-4.0.2.tgz#7159dcf626118d33e299f485d6afe4aff7c4a3e3"
|
||||
integrity sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==
|
||||
dependencies:
|
||||
lilconfig "^2.0.5"
|
||||
yaml "^1.10.2"
|
||||
lilconfig "^3.0.0"
|
||||
yaml "^2.3.4"
|
||||
|
||||
postcss-loader@^4.2.0:
|
||||
version "4.3.0"
|
||||
|
@ -15762,12 +15884,12 @@ postcss-modules@^4.0.0:
|
|||
postcss-modules-values "^4.0.0"
|
||||
string-hash "^1.1.1"
|
||||
|
||||
postcss-nested@6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.0.0.tgz#1572f1984736578f360cffc7eb7dca69e30d1735"
|
||||
integrity sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==
|
||||
postcss-nested@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.0.1.tgz#f83dc9846ca16d2f4fa864f16e9d9f7d0961662c"
|
||||
integrity sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==
|
||||
dependencies:
|
||||
postcss-selector-parser "^6.0.10"
|
||||
postcss-selector-parser "^6.0.11"
|
||||
|
||||
postcss-normalize-charset@^5.0.2:
|
||||
version "5.0.2"
|
||||
|
@ -15855,7 +15977,7 @@ postcss-reduce-transforms@^5.0.3:
|
|||
dependencies:
|
||||
postcss-value-parser "^4.2.0"
|
||||
|
||||
postcss-selector-parser@6.0.10, postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.10, postcss-selector-parser@^6.0.2:
|
||||
postcss-selector-parser@6.0.10, postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2:
|
||||
version "6.0.10"
|
||||
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d"
|
||||
integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==
|
||||
|
@ -15863,6 +15985,14 @@ postcss-selector-parser@6.0.10, postcss-selector-parser@^6.0.0, postcss-selector
|
|||
cssesc "^3.0.0"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
postcss-selector-parser@^6.0.11:
|
||||
version "6.0.15"
|
||||
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz#11cc2b21eebc0b99ea374ffb9887174855a01535"
|
||||
integrity sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==
|
||||
dependencies:
|
||||
cssesc "^3.0.0"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5:
|
||||
version "6.0.9"
|
||||
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.9.tgz#ee71c3b9ff63d9cd130838876c13a2ec1a992b2f"
|
||||
|
@ -15900,7 +16030,7 @@ postcss@8.4.14, postcss@^8.4.14:
|
|||
picocolors "^1.0.0"
|
||||
source-map-js "^1.0.2"
|
||||
|
||||
postcss@8.4.19, postcss@^8.4.18:
|
||||
postcss@8.4.19:
|
||||
version "8.4.19"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.19.tgz#61178e2add236b17351897c8bcc0b4c8ecab56fc"
|
||||
integrity sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA==
|
||||
|
@ -15935,6 +16065,15 @@ postcss@^8.3.11:
|
|||
picocolors "^1.0.0"
|
||||
source-map-js "^1.0.2"
|
||||
|
||||
postcss@^8.4.23:
|
||||
version "8.4.33"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.33.tgz#1378e859c9f69bf6f638b990a0212f43e2aaa742"
|
||||
integrity sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==
|
||||
dependencies:
|
||||
nanoid "^3.3.7"
|
||||
picocolors "^1.0.0"
|
||||
source-map-js "^1.0.2"
|
||||
|
||||
postgres-array@~2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e"
|
||||
|
@ -17048,7 +17187,7 @@ resolve@^1.1.7, resolve@^1.12.0, resolve@^1.17.0, resolve@^1.20.0:
|
|||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
resolve@^1.10.0, resolve@^1.14.2, resolve@^1.19.0, resolve@^1.22.0, resolve@^1.22.1, resolve@^1.3.2:
|
||||
resolve@^1.10.0, resolve@^1.14.2, resolve@^1.19.0, resolve@^1.22.0, resolve@^1.3.2:
|
||||
version "1.22.1"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
|
||||
integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
|
||||
|
@ -17057,6 +17196,15 @@ resolve@^1.10.0, resolve@^1.14.2, resolve@^1.19.0, resolve@^1.22.0, resolve@^1.2
|
|||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
resolve@^1.22.2:
|
||||
version "1.22.8"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d"
|
||||
integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==
|
||||
dependencies:
|
||||
is-core-module "^2.13.0"
|
||||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
resolve@^2.0.0-next.3:
|
||||
version "2.0.0-next.3"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.3.tgz#d41016293d4a8586a39ca5d9b5f15cbea1f55e46"
|
||||
|
@ -17651,6 +17799,11 @@ signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7:
|
|||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
|
||||
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
|
||||
|
||||
signal-exit@^4.0.1:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
|
||||
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
|
||||
|
||||
simple-swizzle@^0.2.2:
|
||||
version "0.2.2"
|
||||
resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
|
||||
|
@ -18080,7 +18233,7 @@ string-length@^4.0.1:
|
|||
char-regex "^1.0.2"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
|
@ -18098,6 +18251,15 @@ string-width@^5.0.0:
|
|||
emoji-regex "^9.2.2"
|
||||
strip-ansi "^7.0.1"
|
||||
|
||||
string-width@^5.0.1, string-width@^5.1.2:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
|
||||
integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==
|
||||
dependencies:
|
||||
eastasianwidth "^0.2.0"
|
||||
emoji-regex "^9.2.2"
|
||||
strip-ansi "^7.0.1"
|
||||
|
||||
string.prototype.codepointat@^0.2.1:
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz#004ad44c8afc727527b108cd462b4d971cd469bc"
|
||||
|
@ -18199,6 +18361,13 @@ string_decoder@~1.1.1:
|
|||
dependencies:
|
||||
safe-buffer "~5.1.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
|
||||
|
@ -18206,13 +18375,6 @@ strip-ansi@^3.0.1:
|
|||
dependencies:
|
||||
ansi-regex "^2.0.0"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^7.0.1:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2"
|
||||
|
@ -18362,6 +18524,19 @@ stylus@^0.55.0:
|
|||
semver "^6.3.0"
|
||||
source-map "^0.7.3"
|
||||
|
||||
sucrase@^3.32.0:
|
||||
version "3.35.0"
|
||||
resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.0.tgz#57f17a3d7e19b36d8995f06679d121be914ae263"
|
||||
integrity sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==
|
||||
dependencies:
|
||||
"@jridgewell/gen-mapping" "^0.3.2"
|
||||
commander "^4.0.0"
|
||||
glob "^10.3.10"
|
||||
lines-and-columns "^1.1.6"
|
||||
mz "^2.7.0"
|
||||
pirates "^4.0.1"
|
||||
ts-interface-checker "^0.1.9"
|
||||
|
||||
superjson@^1.10.0, superjson@^1.11.0:
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/superjson/-/superjson-1.11.0.tgz#f6e2ae0d8fbac61c3fca09ab6739ac9678414d1b"
|
||||
|
@ -18454,34 +18629,33 @@ synckit@^0.8.4:
|
|||
"@pkgr/utils" "^2.3.1"
|
||||
tslib "^2.4.0"
|
||||
|
||||
tailwindcss@3.2.4:
|
||||
version "3.2.4"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.2.4.tgz#afe3477e7a19f3ceafb48e4b083e292ce0dc0250"
|
||||
integrity sha512-AhwtHCKMtR71JgeYDaswmZXhPcW9iuI9Sp2LvZPo9upDZ7231ZJ7eA9RaURbhpXGVlrjX4cFNlB4ieTetEb7hQ==
|
||||
tailwindcss@^3.4.1:
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.1.tgz#f512ca5d1dd4c9503c7d3d28a968f1ad8f5c839d"
|
||||
integrity sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==
|
||||
dependencies:
|
||||
"@alloc/quick-lru" "^5.2.0"
|
||||
arg "^5.0.2"
|
||||
chokidar "^3.5.3"
|
||||
color-name "^1.1.4"
|
||||
detective "^5.2.1"
|
||||
didyoumean "^1.2.2"
|
||||
dlv "^1.1.3"
|
||||
fast-glob "^3.2.12"
|
||||
fast-glob "^3.3.0"
|
||||
glob-parent "^6.0.2"
|
||||
is-glob "^4.0.3"
|
||||
lilconfig "^2.0.6"
|
||||
jiti "^1.19.1"
|
||||
lilconfig "^2.1.0"
|
||||
micromatch "^4.0.5"
|
||||
normalize-path "^3.0.0"
|
||||
object-hash "^3.0.0"
|
||||
picocolors "^1.0.0"
|
||||
postcss "^8.4.18"
|
||||
postcss-import "^14.1.0"
|
||||
postcss-js "^4.0.0"
|
||||
postcss-load-config "^3.1.4"
|
||||
postcss-nested "6.0.0"
|
||||
postcss-selector-parser "^6.0.10"
|
||||
postcss-value-parser "^4.2.0"
|
||||
quick-lru "^5.1.1"
|
||||
resolve "^1.22.1"
|
||||
postcss "^8.4.23"
|
||||
postcss-import "^15.1.0"
|
||||
postcss-js "^4.0.1"
|
||||
postcss-load-config "^4.0.1"
|
||||
postcss-nested "^6.0.1"
|
||||
postcss-selector-parser "^6.0.11"
|
||||
resolve "^1.22.2"
|
||||
sucrase "^3.32.0"
|
||||
|
||||
tapable@^1.0.0, tapable@^1.1.3:
|
||||
version "1.1.3"
|
||||
|
@ -18666,6 +18840,20 @@ text-table@^0.2.0:
|
|||
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
|
||||
integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
|
||||
|
||||
thenify-all@^1.0.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
|
||||
integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==
|
||||
dependencies:
|
||||
thenify ">= 3.1.0 < 4"
|
||||
|
||||
"thenify@>= 3.1.0 < 4":
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f"
|
||||
integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==
|
||||
dependencies:
|
||||
any-promise "^1.0.0"
|
||||
|
||||
throttleit@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c"
|
||||
|
@ -18850,6 +19038,11 @@ ts-essentials@^7.0.3:
|
|||
resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-7.0.3.tgz#686fd155a02133eedcc5362dc8b5056cde3e5a38"
|
||||
integrity sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ==
|
||||
|
||||
ts-interface-checker@^0.1.9:
|
||||
version "0.1.13"
|
||||
resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
|
||||
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
|
||||
|
||||
ts-jest@28.0.5:
|
||||
version "28.0.5"
|
||||
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-28.0.5.tgz#31776f768fba6dfc8c061d488840ed0c8eeac8b9"
|
||||
|
@ -20045,6 +20238,15 @@ worker-rpc@^0.1.0:
|
|||
dependencies:
|
||||
microevent.ts "~0.1.1"
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^6.2.0:
|
||||
version "6.2.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
|
||||
|
@ -20054,14 +20256,14 @@ wrap-ansi@^6.2.0:
|
|||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
ansi-styles "^6.1.0"
|
||||
string-width "^5.0.1"
|
||||
strip-ansi "^7.0.1"
|
||||
|
||||
wrappy@1:
|
||||
version "1.0.2"
|
||||
|
@ -20118,7 +20320,7 @@ xmlchars@^2.2.0:
|
|||
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
|
||||
integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
|
||||
|
||||
xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.1:
|
||||
xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
||||
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
|
||||
|
@ -20158,6 +20360,11 @@ yaml@^1.10.0, yaml@^1.10.2, yaml@^1.7.2:
|
|||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
|
||||
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
|
||||
|
||||
yaml@^2.3.4:
|
||||
version "2.3.4"
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.4.tgz#53fc1d514be80aabf386dc6001eb29bf3b7523b2"
|
||||
integrity sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==
|
||||
|
||||
yargs-parser@21.1.1, yargs-parser@^21.0.1, yargs-parser@^21.1.1:
|
||||
version "21.1.1"
|
||||
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue