mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
Merge remote-tracking branch 'upstream/main' into claim-131-ui
This commit is contained in:
commit
5764618b0d
72 changed files with 1482 additions and 423 deletions
|
@ -14,6 +14,7 @@ NX_NEXTAUTH_URL=http://localhost:4200
|
||||||
# We use ngrok to expose a local development environment to the internet
|
# We use ngrok to expose a local development environment to the internet
|
||||||
# You can sign up for a free account and get an API key at https://ngrok.com
|
# You can sign up for a free account and get an API key at https://ngrok.com
|
||||||
NGROK_AUTH_TOKEN=
|
NGROK_AUTH_TOKEN=
|
||||||
|
NGROK_DOMAIN=
|
||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
# DATABASE
|
# DATABASE
|
||||||
|
@ -35,7 +36,8 @@ NX_POLYGON_API_KEY=
|
||||||
# We use Teller.io for automated banking data. You can sign up for a free
|
# We use Teller.io for automated banking data. You can sign up for a free
|
||||||
# account and get a free API key at https://teller.io
|
# account and get a free API key at https://teller.io
|
||||||
NX_TELLER_SIGNING_SECRET=
|
NX_TELLER_SIGNING_SECRET=
|
||||||
NX_TELLER_APP_ID=
|
NEXT_PUBLIC_TELLER_APP_ID=
|
||||||
|
NEXT_PUBLIC_TELLER_ENV=sandbox
|
||||||
NX_TELLER_ENV=sandbox
|
NX_TELLER_ENV=sandbox
|
||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
@ -8,5 +8,7 @@
|
||||||
},
|
},
|
||||||
"[html]": {
|
"[html]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
}
|
},
|
||||||
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
}
|
}
|
||||||
|
|
40
README.md
40
README.md
|
@ -4,10 +4,6 @@
|
||||||
|
|
||||||
<b>Get involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybe.co) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
|
<b>Get involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybe.co) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
|
||||||
|
|
||||||
🚨 NOTE: This is the original React app of the previously-defunct personal finance app, Maybe. This original version used many external services (Plaid, Finicity, etc) and getting it to fully function will be a decent amount of work.
|
|
||||||
|
|
||||||
There's a LOT of work to do to get this functioning, but it should be feasible.
|
|
||||||
|
|
||||||
## Backstory
|
## Backstory
|
||||||
|
|
||||||
We spent the better part of 2021/2022 building a personal finance + wealth management app called Maybe. Very full-featured, including an "Ask an Advisor" feature which connected users with an actual CFP/CFA to help them with their finances (all included in your subscription).
|
We spent the better part of 2021/2022 building a personal finance + wealth management app called Maybe. Very full-featured, including an "Ask an Advisor" feature which connected users with an actual CFP/CFA to help them with their finances (all included in your subscription).
|
||||||
|
@ -37,11 +33,12 @@ As a personal finance + wealth management app, Maybe has a lot of features. Here
|
||||||
|
|
||||||
And dozens upon dozens of smaller features.
|
And dozens upon dozens of smaller features.
|
||||||
|
|
||||||
## Building the app
|
## Getting started
|
||||||
|
|
||||||
This is the current state of building the app. You'll hit errors, which we're working to resolve (and certainly welcome PRs to help with that).
|
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`:
|
First, copy the `.env.example` file to `.env`:
|
||||||
|
|
||||||
|
@ -53,6 +50,14 @@ Then, create a new secret using `openssl rand -base64 32` and populate `NEXTAUTH
|
||||||
|
|
||||||
To enable transactional emails, you'll need to create a [Postmark](https://postmarkapp.com/) account and add your API key to your `.env` file (`NX_POSTMARK_API_TOKEN`). You can also set the from and reply-to email addresses (`NX_POSTMARK_FROM_ADDRESS` and `NX_POSTMARK_REPLY_TO_ADDRESS`). If you want to run the app without email, you can set `NX_POSTMARK_API_TOKEN` to a dummy value.
|
To enable transactional emails, you'll need to create a [Postmark](https://postmarkapp.com/) account and add your API key to your `.env` file (`NX_POSTMARK_API_TOKEN`). You can also set the from and reply-to email addresses (`NX_POSTMARK_FROM_ADDRESS` and `NX_POSTMARK_REPLY_TO_ADDRESS`). If you want to run the app without email, you can set `NX_POSTMARK_API_TOKEN` to a dummy value.
|
||||||
|
|
||||||
|
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.
|
||||||
|
- 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:
|
Then run the following yarn commands:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
@ -63,6 +68,27 @@ yarn prisma:seed
|
||||||
yarn dev
|
yarn dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Set Up Ngrok
|
||||||
|
|
||||||
|
External data providers require HTTPS/SSL webhook URLs for sending data.
|
||||||
|
|
||||||
|
To test this locally/during development, you will need to setup `ngrok`.
|
||||||
|
|
||||||
|
1. Visit [ngrok.com](https://ngrok.com/)
|
||||||
|
2. Create a free account
|
||||||
|
3. Visit [this page](https://dashboard.ngrok.com/get-started/your-authtoken) to access your auth token
|
||||||
|
4. Paste it into your `.env` file: `NGROK_AUTH_TOKEN=your_auth_token`
|
||||||
|
|
||||||
|
You should claim your free static domain to avoid needing to change the URL each time you start/stop the server.
|
||||||
|
|
||||||
|
To do so:
|
||||||
|
|
||||||
|
1. Visit the [domains](https://dashboard.ngrok.com/cloud-edge/domains) page
|
||||||
|
2. Click on Create Domain
|
||||||
|
3. Copy the domain and paste it into your `.env` file: `NGROK_DOMAIN=your_domain`
|
||||||
|
|
||||||
|
That's it! As long as you run the project locally using `docker` with `yarn dev:services:all` you'll be good to go.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
To contribute, please see our [contribution guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md).
|
To contribute, please see our [contribution guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md).
|
||||||
|
|
|
@ -85,7 +85,6 @@ export const authOptions = {
|
||||||
strategy: 'jwt' as SessionStrategy,
|
strategy: 'jwt' as SessionStrategy,
|
||||||
maxAge: 1 * 24 * 60 * 60, // 1 Day
|
maxAge: 1 * 24 * 60 * 60, // 1 Day
|
||||||
},
|
},
|
||||||
|
|
||||||
providers: [
|
providers: [
|
||||||
CredentialsProvider({
|
CredentialsProvider({
|
||||||
name: 'Credentials',
|
name: 'Credentials',
|
||||||
|
|
|
@ -57,6 +57,7 @@ export default function LoginPage() {
|
||||||
<form className="space-y-4 w-full px-4" onSubmit={onSubmit}>
|
<form className="space-y-4 w-full px-4" onSubmit={onSubmit}>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
name="email"
|
||||||
label="Email"
|
label="Email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||||
|
@ -64,6 +65,7 @@ export default function LoginPage() {
|
||||||
|
|
||||||
<InputPassword
|
<InputPassword
|
||||||
autoComplete="password"
|
autoComplete="password"
|
||||||
|
name="password"
|
||||||
label="Password"
|
label="Password"
|
||||||
value={password}
|
value={password}
|
||||||
showPasswordRequirements={!isValid}
|
showPasswordRequirements={!isValid}
|
||||||
|
|
|
@ -63,18 +63,21 @@ export default function RegisterPage() {
|
||||||
<form className="space-y-4 w-full px-4" onSubmit={onSubmit}>
|
<form className="space-y-4 w-full px-4" onSubmit={onSubmit}>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
name="firstName"
|
||||||
label="First name"
|
label="First name"
|
||||||
value={firstName}
|
value={firstName}
|
||||||
onChange={(e) => setFirstName(e.currentTarget.value)}
|
onChange={(e) => setFirstName(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
name="lastName"
|
||||||
label="Last name"
|
label="Last name"
|
||||||
value={lastName}
|
value={lastName}
|
||||||
onChange={(e) => setLastName(e.currentTarget.value)}
|
onChange={(e) => setLastName(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
name="email"
|
||||||
label="Email"
|
label="Email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||||
|
@ -82,6 +85,7 @@ export default function RegisterPage() {
|
||||||
|
|
||||||
<InputPassword
|
<InputPassword
|
||||||
autoComplete="password"
|
autoComplete="password"
|
||||||
|
name="password"
|
||||||
label="Password"
|
label="Password"
|
||||||
value={password}
|
value={password}
|
||||||
showPasswordRequirements={!isValid}
|
showPasswordRequirements={!isValid}
|
||||||
|
|
|
@ -35,7 +35,7 @@ export default function SettingsPage() {
|
||||||
<Tab.List>
|
<Tab.List>
|
||||||
<Tab>Details</Tab>
|
<Tab>Details</Tab>
|
||||||
<Tab>Security</Tab>
|
<Tab>Security</Tab>
|
||||||
<Tab>Billing</Tab>
|
{process.env.STRIPE_API_KEY && <Tab>Billing</Tab>}
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
<Tab.Panel>
|
<Tab.Panel>
|
||||||
|
@ -46,9 +46,11 @@ export default function SettingsPage() {
|
||||||
<SecurityPreferences />
|
<SecurityPreferences />
|
||||||
</div>
|
</div>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel>
|
{process.env.STRIPE_API_KEY && (
|
||||||
<BillingPreferences />
|
<Tab.Panel>
|
||||||
</Tab.Panel>
|
<BillingPreferences />
|
||||||
|
</Tab.Panel>
|
||||||
|
)}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -11,14 +11,10 @@ export default defineConfig({
|
||||||
baseUrl: 'http://localhost:4200',
|
baseUrl: 'http://localhost:4200',
|
||||||
env: {
|
env: {
|
||||||
API_URL: 'http://localhost:3333/v1',
|
API_URL: 'http://localhost:3333/v1',
|
||||||
AUTH0_ID: 'REPLACE_THIS',
|
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||||
AUTH0_CLIENT_ID: 'REPLACE_THIS',
|
NEXT_PUBLIC_NEXTAUTH_URL: 'http://localhost:4200',
|
||||||
AUTH0_NAME: 'Engineering CI',
|
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||||
AUTH0_EMAIL: 'REPLACE_THIS',
|
STRIPE_WEBHOOK_SECRET: 'REPLACE_THIS',
|
||||||
AUTH0_PASSWORD: 'REPLACE_THIS',
|
|
||||||
AUTH0_DOMAIN: 'REPLACE_THIS',
|
|
||||||
STRIPE_WEBHOOK_SECRET:
|
|
||||||
'REPLACE_THIS',
|
|
||||||
STRIPE_CUSTOMER_ID: 'REPLACE_THIS',
|
STRIPE_CUSTOMER_ID: 'REPLACE_THIS',
|
||||||
STRIPE_SUBSCRIPTION_ID: 'REPLACE_THIS',
|
STRIPE_SUBSCRIPTION_ID: 'REPLACE_THIS',
|
||||||
},
|
},
|
||||||
|
|
|
@ -19,81 +19,6 @@ function openEditAccountModal() {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Accounts', () => {
|
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', () => {
|
it('should interpolate and display manual vehicle account data', () => {
|
||||||
cy.getByTestId('add-account-button').click()
|
cy.getByTestId('add-account-button').click()
|
||||||
cy.contains('h4', 'Add account')
|
cy.contains('h4', 'Add account')
|
||||||
|
@ -149,6 +74,8 @@ describe('Accounts', () => {
|
||||||
cy.contains('h4', 'Add real estate')
|
cy.contains('h4', 'Add real estate')
|
||||||
|
|
||||||
// Details
|
// Details
|
||||||
|
cy.contains('label', 'Country').click()
|
||||||
|
cy.contains('button', 'Uganda').click()
|
||||||
cy.get('input[name="line1"]').focus().type('123 Example St')
|
cy.get('input[name="line1"]').focus().type('123 Example St')
|
||||||
cy.get('input[name="city"]').type('New York')
|
cy.get('input[name="city"]').type('New York')
|
||||||
cy.get('input[name="state"]').type('NY')
|
cy.get('input[name="state"]').type('NY')
|
||||||
|
@ -187,6 +114,9 @@ describe('Accounts', () => {
|
||||||
openEditAccountModal()
|
openEditAccountModal()
|
||||||
|
|
||||||
cy.getByTestId('property-form').within(() => {
|
cy.getByTestId('property-form').within(() => {
|
||||||
|
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"]')
|
cy.get('input[name="line1"]')
|
||||||
.should('have.value', '123 Example St')
|
.should('have.value', '123 Example St')
|
||||||
.clear()
|
.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'
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
declare namespace Cypress {
|
||||||
declare global {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
interface Chainable<Subject> {
|
||||||
namespace Cypress {
|
login(): Chainable<any>
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
apiRequest(...params: Parameters<typeof cy.request>): Chainable<any>
|
||||||
interface Chainable<Subject> {
|
getByTestId(...parameters: Parameters<typeof cy.get>): Chainable<any>
|
||||||
login(username: string, password: string): Chainable<any>
|
selectDate(date: Date): Chainable<any>
|
||||||
apiRequest(...params: Parameters<typeof cy.request>): Chainable<any>
|
preserveAccessToken(): Chainable<any>
|
||||||
getByTestId(...parameters: Parameters<typeof cy.get>): Chainable<any>
|
restoreAccessToken(): 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) => {
|
Cypress.Commands.add('apiRequest', ({ url, headers = {}, ...options }, ...rest) => {
|
||||||
const accessToken = window.localStorage.getItem('token')
|
|
||||||
|
|
||||||
return cy.request(
|
return cy.request(
|
||||||
{
|
{
|
||||||
url: `${Cypress.env('API_URL')}/${url}`,
|
url: `${Cypress.env('API_URL')}/${url}`,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
...headers,
|
...headers,
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
|
@ -35,74 +28,11 @@ Cypress.Commands.add('apiRequest', ({ url, headers = {}, ...options }, ...rest)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
Cypress.Commands.add('login', () => {
|
||||||
* Logs in with the engineering CI account
|
cy.visit('/login')
|
||||||
*/
|
cy.get('input[name="email"]').type('bond@007.com')
|
||||||
Cypress.Commands.add('login', (username, password) => {
|
cy.get('input[name="password"]').type('TestPassword123')
|
||||||
// Preserves login across tests
|
cy.get('button[type="submit"]').click()
|
||||||
cy.session('login-session-key', login, {
|
//eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||||
validate() {
|
cy.wait(1000)
|
||||||
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('/')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
|
@ -2,8 +2,7 @@ import './commands'
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Login
|
// 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()
|
||||||
cy.login(Cypress.env('AUTH0_EMAIL'), Cypress.env('AUTH0_PASSWORD'))
|
|
||||||
|
|
||||||
// Delete the current user to wipe all data before test
|
// Delete the current user to wipe all data before test
|
||||||
cy.apiRequest({
|
cy.apiRequest({
|
||||||
|
@ -14,6 +13,6 @@ beforeEach(() => {
|
||||||
expect(response.status).to.equal(200)
|
expect(response.status).to.equal(200)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Re-login (JWT should still be valid)
|
// Go back to dashboard
|
||||||
cy.visit('/')
|
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'
|
|
@ -9,7 +9,6 @@ import {
|
||||||
} from '@maybe-finance/server/features'
|
} from '@maybe-finance/server/features'
|
||||||
import { InMemoryQueueFactory, PgService, type IQueueFactory } from '@maybe-finance/server/shared'
|
import { InMemoryQueueFactory, PgService, type IQueueFactory } from '@maybe-finance/server/shared'
|
||||||
import { createLogger, transports } from 'winston'
|
import { createLogger, transports } from 'winston'
|
||||||
import isCI from 'is-ci'
|
|
||||||
import nock from 'nock'
|
import nock from 'nock'
|
||||||
import Decimal from 'decimal.js'
|
import Decimal from 'decimal.js'
|
||||||
import { startServer, stopServer } from './utils/server'
|
import { startServer, stopServer } from './utils/server'
|
||||||
|
@ -27,7 +26,7 @@ const prisma = new PrismaClient()
|
||||||
// For TypeScript support
|
// For TypeScript support
|
||||||
const plaid = jest.mocked(_plaid) // eslint-disable-line
|
const plaid = jest.mocked(_plaid) // eslint-disable-line
|
||||||
|
|
||||||
const auth0Id = isCI ? 'auth0|61afd38f678a0c006895f046' : 'auth0|61afd340678a0c006895f000'
|
const authId = '__TEST_USER_ID__'
|
||||||
let axios: AxiosInstance
|
let axios: AxiosInstance
|
||||||
let user: User
|
let user: User
|
||||||
|
|
||||||
|
@ -38,7 +37,7 @@ if (process.env.IS_VSCODE_DEBUG === 'true') {
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Clears old user and data, creates new user
|
// Clears old user and data, creates new user
|
||||||
user = await resetUser(auth0Id)
|
user = await resetUser(authId)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('/v1/accounts API', () => {
|
describe('/v1/accounts API', () => {
|
||||||
|
|
|
@ -2,7 +2,6 @@ import type { AxiosInstance } from 'axios'
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
import type { Prisma, AccountConnection, AccountSyncStatus, User } from '@prisma/client'
|
import type { Prisma, AccountConnection, AccountSyncStatus, User } from '@prisma/client'
|
||||||
import type { ItemRemoveResponse } from 'plaid'
|
import type { ItemRemoveResponse } from 'plaid'
|
||||||
import isCI from 'is-ci'
|
|
||||||
import { startServer, stopServer } from './utils/server'
|
import { startServer, stopServer } from './utils/server'
|
||||||
import { getAxiosClient } from './utils/axios'
|
import { getAxiosClient } from './utils/axios'
|
||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
|
@ -18,7 +17,7 @@ jest.mock('plaid')
|
||||||
// For TypeScript support
|
// For TypeScript support
|
||||||
const plaid = jest.mocked(_plaid)
|
const plaid = jest.mocked(_plaid)
|
||||||
|
|
||||||
const auth0Id = isCI ? 'auth0|61afd38f678a0c006895f046' : 'auth0|61afd340678a0c006895f000'
|
const authId = '__TEST_USER_ID__'
|
||||||
let axios: AxiosInstance
|
let axios: AxiosInstance
|
||||||
let user: User | null
|
let user: User | null
|
||||||
let connection: AccountConnection
|
let connection: AccountConnection
|
||||||
|
@ -45,7 +44,7 @@ afterAll(async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
user = await resetUser(auth0Id)
|
user = await resetUser(authId)
|
||||||
|
|
||||||
connectionData = {
|
connectionData = {
|
||||||
data: {
|
data: {
|
||||||
|
|
|
@ -1,38 +1,37 @@
|
||||||
import type { AxiosResponse } from 'axios'
|
import type { AxiosResponse } from 'axios'
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
import { superjson } from '@maybe-finance/shared'
|
import { superjson } from '@maybe-finance/shared'
|
||||||
import env from '../../../env'
|
|
||||||
import isCI from 'is-ci'
|
|
||||||
import Axios from 'axios'
|
import Axios from 'axios'
|
||||||
|
import { encode } from 'next-auth/jwt'
|
||||||
|
|
||||||
// Fetches Auth0 access token (JWT) and prepares Axios client to use it on each request
|
|
||||||
export async function getAxiosClient() {
|
export async function getAxiosClient() {
|
||||||
const tenantUrl = isCI
|
const baseUrl = 'http://127.0.0.1:53333/v1'
|
||||||
? 'REPLACE_THIS-staging.us.auth0.com'
|
const jwt = await encode({
|
||||||
: 'REPLACE_THIS-development.us.auth0.com'
|
maxAge: 1 * 24 * 60 * 60,
|
||||||
|
secret: process.env.NEXTAUTH_SECRET || 'CHANGE_ME',
|
||||||
const {
|
token: {
|
||||||
data: { access_token: token },
|
sub: '__TEST_USER_ID__',
|
||||||
} = await Axios.request({
|
user: '__TEST_USER_ID__',
|
||||||
method: 'POST',
|
'https://maybe.co/email': 'REPLACE_THIS',
|
||||||
url: `https://${tenantUrl}/oauth/token`,
|
firstName: 'REPLACE_THIS',
|
||||||
headers: { 'content-type': 'application/json' },
|
lastName: 'REPLACE_THIS',
|
||||||
data: {
|
name: 'REPLACE_THIS',
|
||||||
grant_type: 'password',
|
|
||||||
username: 'REPLACE_THIS',
|
|
||||||
password: 'REPLACE_THIS',
|
|
||||||
audience: 'https://maybe-finance-api/v1',
|
|
||||||
scope: '',
|
|
||||||
client_id: isCI ? 'REPLACE_THIS' : 'REPLACE_THIS',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const defaultHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Access-Control-Allow-Credentials': true,
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
}
|
||||||
|
const axiosOptions = {
|
||||||
|
baseURL: baseUrl,
|
||||||
|
headers: defaultHeaders,
|
||||||
|
}
|
||||||
|
|
||||||
const axios = Axios.create({
|
const axios = Axios.create({
|
||||||
baseURL: 'http://127.0.0.1:53333/v1',
|
...axiosOptions,
|
||||||
validateStatus: () => true, // Tests should determine whether status is correct, not Axios
|
validateStatus: () => true, // Tests should determine whether status is correct, not Axios
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
axios.interceptors.response.use((response: AxiosResponse<SharedType.BaseResponse>) => {
|
axios.interceptors.response.use((response: AxiosResponse<SharedType.BaseResponse>) => {
|
||||||
|
|
|
@ -36,6 +36,7 @@ import {
|
||||||
valuationsRouter,
|
valuationsRouter,
|
||||||
institutionsRouter,
|
institutionsRouter,
|
||||||
finicityRouter,
|
finicityRouter,
|
||||||
|
tellerRouter,
|
||||||
transactionsRouter,
|
transactionsRouter,
|
||||||
holdingsRouter,
|
holdingsRouter,
|
||||||
securitiesRouter,
|
securitiesRouter,
|
||||||
|
@ -43,6 +44,7 @@ import {
|
||||||
toolsRouter,
|
toolsRouter,
|
||||||
publicRouter,
|
publicRouter,
|
||||||
e2eRouter,
|
e2eRouter,
|
||||||
|
adminRouter,
|
||||||
} from './routes'
|
} from './routes'
|
||||||
import env from '../env'
|
import env from '../env'
|
||||||
|
|
||||||
|
@ -92,6 +94,7 @@ app.use(cors({ origin, credentials: true }))
|
||||||
app.options('*', cors() as RequestHandler)
|
app.options('*', cors() as RequestHandler)
|
||||||
|
|
||||||
app.set('view engine', 'ejs').set('views', __dirname + '/app/admin/views')
|
app.set('view engine', 'ejs').set('views', __dirname + '/app/admin/views')
|
||||||
|
app.use('/admin', adminRouter)
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
morgan(env.NX_MORGAN_LOG_LEVEL, {
|
morgan(env.NX_MORGAN_LOG_LEVEL, {
|
||||||
|
@ -156,6 +159,7 @@ app.use('/v1/users', usersRouter)
|
||||||
app.use('/v1/e2e', e2eRouter)
|
app.use('/v1/e2e', e2eRouter)
|
||||||
app.use('/v1/plaid', plaidRouter)
|
app.use('/v1/plaid', plaidRouter)
|
||||||
app.use('/v1/finicity', finicityRouter)
|
app.use('/v1/finicity', finicityRouter)
|
||||||
|
app.use('/v1/teller', tellerRouter)
|
||||||
app.use('/v1/accounts', accountsRouter)
|
app.use('/v1/accounts', accountsRouter)
|
||||||
app.use('/v1/account-rollup', accountRollupRouter)
|
app.use('/v1/account-rollup', accountRollupRouter)
|
||||||
app.use('/v1/connections', connectionsRouter)
|
app.use('/v1/connections', connectionsRouter)
|
||||||
|
|
|
@ -240,6 +240,7 @@ const userService = new UserService(
|
||||||
const institutionProviderFactory = new InstitutionProviderFactory({
|
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||||
PLAID: plaidService,
|
PLAID: plaidService,
|
||||||
FINICITY: finicityService,
|
FINICITY: finicityService,
|
||||||
|
TELLER: tellerService,
|
||||||
})
|
})
|
||||||
|
|
||||||
const institutionService: IInstitutionService = new InstitutionService(
|
const institutionService: IInstitutionService = new InstitutionService(
|
||||||
|
|
|
@ -2,17 +2,20 @@ import cookieParser from 'cookie-parser'
|
||||||
import { decode } from 'next-auth/jwt'
|
import { decode } from 'next-auth/jwt'
|
||||||
|
|
||||||
const SECRET = process.env.NEXTAUTH_SECRET ?? 'REPLACE_THIS'
|
const SECRET = process.env.NEXTAUTH_SECRET ?? 'REPLACE_THIS'
|
||||||
|
|
||||||
export const validateAuthJwt = async (req, res, next) => {
|
export const validateAuthJwt = async (req, res, next) => {
|
||||||
cookieParser(SECRET)(req, res, async (err) => {
|
cookieParser(SECRET)(req, res, async (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.cookies && 'next-auth.session-token' in req.cookies) {
|
const cookieName = req.secure
|
||||||
|
? '__Secure-next-auth.session-token'
|
||||||
|
: 'next-auth.session-token'
|
||||||
|
|
||||||
|
if (req.cookies && cookieName in req.cookies) {
|
||||||
try {
|
try {
|
||||||
const token = await decode({
|
const token = await decode({
|
||||||
token: req.cookies['next-auth.session-token'],
|
token: req.cookies[cookieName],
|
||||||
secret: SECRET,
|
secret: SECRET,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -26,6 +29,18 @@ export const validateAuthJwt = async (req, res, next) => {
|
||||||
console.error('Error in token validation', error)
|
console.error('Error in token validation', error)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
}
|
}
|
||||||
|
} else if (req.headers.authorization) {
|
||||||
|
const token = req.headers.authorization.split(' ')[1]
|
||||||
|
const decoded = await decode({
|
||||||
|
token,
|
||||||
|
secret: SECRET,
|
||||||
|
})
|
||||||
|
if (decoded) {
|
||||||
|
req.user = decoded
|
||||||
|
return next()
|
||||||
|
} else {
|
||||||
|
return res.status(401).json({ message: 'Unauthorized' })
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return res.status(401).json({ message: 'Unauthorized' })
|
return res.status(401).json({ message: 'Unauthorized' })
|
||||||
}
|
}
|
||||||
|
|
33
apps/server/src/app/routes/admin.router.ts
Normal file
33
apps/server/src/app/routes/admin.router.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { Router } from 'express'
|
||||||
|
import { auth, claimCheck } from 'express-openid-connect'
|
||||||
|
import { createBullBoard } from '@bull-board/api'
|
||||||
|
import { BullAdapter } from '@bull-board/api/bullAdapter'
|
||||||
|
import { ExpressAdapter } from '@bull-board/express'
|
||||||
|
import { AuthUtil, BullQueue } from '@maybe-finance/server/shared'
|
||||||
|
import { SharedType } from '@maybe-finance/shared'
|
||||||
|
import { queueService } from '../lib/endpoint'
|
||||||
|
import env from '../../env'
|
||||||
|
import { validateAuthJwt } from '../middleware'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
const serverAdapter = new ExpressAdapter().setBasePath('/admin/bullmq')
|
||||||
|
|
||||||
|
createBullBoard({
|
||||||
|
queues: queueService.allQueues
|
||||||
|
.filter((q): q is BullQueue => q instanceof BullQueue)
|
||||||
|
.map((q) => new BullAdapter(q.queue)),
|
||||||
|
serverAdapter,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/', validateAuthJwt, (req, res) => {
|
||||||
|
res.render('pages/dashboard', {
|
||||||
|
user: req.user?.name,
|
||||||
|
role: 'Admin',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Visit /admin/bullmq to see BullMQ Dashboard
|
||||||
|
router.use('/bullmq', validateAuthJwt, serverAdapter.getRouter())
|
||||||
|
|
||||||
|
export default router
|
|
@ -6,13 +6,13 @@ import endpoint from '../lib/endpoint'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
router.use((req, res, next) => {
|
const testUserId = 'test_ec3ee8a4-fa01-4f11-8ac5-9c49dd7fbae4'
|
||||||
const roles = req.user?.['https://maybe.co/roles']
|
|
||||||
|
|
||||||
if (roles?.includes('CIUser') || roles?.includes('Admin')) {
|
router.use((req, res, next) => {
|
||||||
|
if (req.user?.sub === testUserId) {
|
||||||
next()
|
next()
|
||||||
} else {
|
} 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),
|
trialLapsed: z.boolean().default(false),
|
||||||
}),
|
}),
|
||||||
resolve: async ({ ctx, input }) => {
|
resolve: async ({ ctx, input }) => {
|
||||||
ctx.logger.debug(`Resetting CI user ${ctx.user!.authId}`)
|
|
||||||
|
|
||||||
await ctx.prisma.$transaction([
|
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({
|
ctx.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
authId: ctx.user!.authId,
|
authId: testUserId,
|
||||||
email: 'REPLACE_THIS',
|
email: 'bond@007.com',
|
||||||
|
firstName: 'James',
|
||||||
|
lastName: 'Bond',
|
||||||
dob: new Date('1990-01-01'),
|
dob: new Date('1990-01-01'),
|
||||||
linkAccountDismissedAt: new Date(), // ensures our auto-account link doesn't trigger
|
linkAccountDismissedAt: new Date(), // ensures our auto-account link doesn't trigger
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ export { default as usersRouter } from './users.router'
|
||||||
export { default as webhooksRouter } from './webhooks.router'
|
export { default as webhooksRouter } from './webhooks.router'
|
||||||
export { default as plaidRouter } from './plaid.router'
|
export { default as plaidRouter } from './plaid.router'
|
||||||
export { default as finicityRouter } from './finicity.router'
|
export { default as finicityRouter } from './finicity.router'
|
||||||
|
export { default as tellerRouter } from './teller.router'
|
||||||
export { default as valuationsRouter } from './valuations.router'
|
export { default as valuationsRouter } from './valuations.router'
|
||||||
export { default as institutionsRouter } from './institutions.router'
|
export { default as institutionsRouter } from './institutions.router'
|
||||||
export { default as transactionsRouter } from './transactions.router'
|
export { default as transactionsRouter } from './transactions.router'
|
||||||
|
@ -14,3 +15,4 @@ export { default as plansRouter } from './plans.router'
|
||||||
export { default as toolsRouter } from './tools.router'
|
export { default as toolsRouter } from './tools.router'
|
||||||
export { default as publicRouter } from './public.router'
|
export { default as publicRouter } from './public.router'
|
||||||
export { default as e2eRouter } from './e2e.router'
|
export { default as e2eRouter } from './e2e.router'
|
||||||
|
export { default as adminRouter } from './admin.router'
|
||||||
|
|
45
apps/server/src/app/routes/teller.router.ts
Normal file
45
apps/server/src/app/routes/teller.router.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import { Router } from 'express'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import endpoint from '../lib/endpoint'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/handle-enrollment',
|
||||||
|
endpoint.create({
|
||||||
|
input: z.object({
|
||||||
|
institution: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
enrollment: z.object({
|
||||||
|
accessToken: z.string(),
|
||||||
|
user: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
enrollment: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
institution: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
signatures: z.array(z.string()).optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
resolve: ({ input: { institution, enrollment }, ctx }) => {
|
||||||
|
return ctx.tellerService.handleEnrollment(ctx.user!.id, institution, enrollment)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/institutions/sync',
|
||||||
|
endpoint.create({
|
||||||
|
resolve: async ({ ctx }) => {
|
||||||
|
ctx.ability.throwUnlessCan('manage', 'Institution')
|
||||||
|
await ctx.queueService.getQueue('sync-institution').add('sync-teller-institutions', {})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
|
@ -48,8 +48,6 @@ const envSchema = z.object({
|
||||||
NX_SENTRY_DSN: z.string().optional(),
|
NX_SENTRY_DSN: z.string().optional(),
|
||||||
NX_SENTRY_ENV: z.string().optional(),
|
NX_SENTRY_ENV: z.string().optional(),
|
||||||
|
|
||||||
NX_LD_SDK_KEY: z.string().default('REPLACE_THIS'),
|
|
||||||
|
|
||||||
NX_POLYGON_API_KEY: z.string().default(''),
|
NX_POLYGON_API_KEY: z.string().default(''),
|
||||||
|
|
||||||
NX_PORT: z.string().default('3333'),
|
NX_PORT: z.string().default('3333'),
|
||||||
|
|
|
@ -68,8 +68,8 @@ describe('Finicity', () => {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
name: 'TEST_FINICITY',
|
name: 'TEST_FINICITY',
|
||||||
type: 'finicity',
|
type: 'finicity',
|
||||||
finicityInstitutionId: 'REPLACE_THIS',
|
finicityInstitutionId: '101732',
|
||||||
finicityInstitutionLoginId: 'REPLACE_THIS',
|
finicityInstitutionLoginId: '6000483842',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -1,22 +1,28 @@
|
||||||
import type { PrismaClient, User } from '@prisma/client'
|
import type { PrismaClient, User } from '@prisma/client'
|
||||||
|
import { faker } from '@faker-js/faker'
|
||||||
|
|
||||||
export async function resetUser(prisma: PrismaClient, authId = 'TODO'): Promise<User> {
|
export async function resetUser(prisma: PrismaClient, authId = '__TEST_USER_ID__'): Promise<User> {
|
||||||
// eslint-disable-next-line
|
try {
|
||||||
const [_, __, ___, user] = await prisma.$transaction([
|
// eslint-disable-next-line
|
||||||
prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${authId};`,
|
const [_, __, ___, user] = await prisma.$transaction([
|
||||||
|
prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${authId};`,
|
||||||
|
|
||||||
// Deleting a user does not cascade to securities, so delete all security records
|
// Deleting a user does not cascade to securities, so delete all security records
|
||||||
prisma.$executeRaw`DELETE from security;`,
|
prisma.$executeRaw`DELETE from security;`,
|
||||||
prisma.$executeRaw`DELETE from security_pricing;`,
|
prisma.$executeRaw`DELETE from security_pricing;`,
|
||||||
|
|
||||||
prisma.user.create({
|
prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
authId,
|
authId,
|
||||||
email: 'test@example.com',
|
email: faker.internet.email(),
|
||||||
finicityCustomerId: 'TEST',
|
finicityCustomerId: faker.string.uuid(),
|
||||||
},
|
tellerUserId: faker.string.uuid(),
|
||||||
}),
|
},
|
||||||
])
|
}),
|
||||||
|
])
|
||||||
return user
|
return user
|
||||||
|
} catch (e) {
|
||||||
|
console.error('error in reset user transaction', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
118
apps/workers/src/app/__tests__/teller.integration.spec.ts
Normal file
118
apps/workers/src/app/__tests__/teller.integration.spec.ts
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
import type { User } from '@prisma/client'
|
||||||
|
import { TellerGenerator } from '../../../../../tools/generators'
|
||||||
|
import { TellerApi } from '@maybe-finance/teller-api'
|
||||||
|
jest.mock('@maybe-finance/teller-api')
|
||||||
|
import {
|
||||||
|
TellerETL,
|
||||||
|
TellerService,
|
||||||
|
type IAccountConnectionProvider,
|
||||||
|
} from '@maybe-finance/server/features'
|
||||||
|
import { createLogger } from '@maybe-finance/server/shared'
|
||||||
|
import prisma from '../lib/prisma'
|
||||||
|
import { resetUser } from './helpers/user.test-helper'
|
||||||
|
import { transports } from 'winston'
|
||||||
|
import { cryptoService } from '../lib/di'
|
||||||
|
|
||||||
|
const logger = createLogger({ level: 'debug', transports: [new transports.Console()] })
|
||||||
|
const teller = jest.mocked(new TellerApi())
|
||||||
|
const tellerETL = new TellerETL(logger, prisma, teller, cryptoService)
|
||||||
|
const service: IAccountConnectionProvider = new TellerService(
|
||||||
|
logger,
|
||||||
|
prisma,
|
||||||
|
teller,
|
||||||
|
tellerETL,
|
||||||
|
cryptoService,
|
||||||
|
'TELLER_WEBHOOK_URL',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Teller', () => {
|
||||||
|
let user: User
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
|
||||||
|
user = await resetUser(prisma)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncs connection', async () => {
|
||||||
|
const tellerConnection = TellerGenerator.generateConnection()
|
||||||
|
const tellerAccounts = tellerConnection.accountsWithBalances
|
||||||
|
const tellerTransactions = tellerConnection.transactions
|
||||||
|
|
||||||
|
teller.getAccounts.mockResolvedValue(tellerAccounts)
|
||||||
|
|
||||||
|
teller.getTransactions.mockImplementation(async ({ accountId }) => {
|
||||||
|
return Promise.resolve(tellerTransactions.filter((t) => t.account_id === accountId))
|
||||||
|
})
|
||||||
|
|
||||||
|
const connection = await prisma.accountConnection.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
name: 'TEST_TELLER',
|
||||||
|
type: 'teller',
|
||||||
|
tellerEnrollmentId: tellerConnection.enrollment.enrollment.id,
|
||||||
|
tellerInstitutionId: tellerConnection.enrollment.institutionId,
|
||||||
|
tellerAccessToken: cryptoService.encrypt(tellerConnection.enrollment.accessToken),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// all accounts
|
||||||
|
expect(accounts).toHaveLength(tellerConnection.accounts.length)
|
||||||
|
for (const account of accounts) {
|
||||||
|
expect(account.transactions).toHaveLength(
|
||||||
|
tellerTransactions.filter((t) => t.account_id === account.tellerAccountId).length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// credit accounts
|
||||||
|
const creditAccounts = tellerAccounts.filter((a) => a.type === 'credit')
|
||||||
|
expect(accounts.filter((a) => a.type === 'CREDIT')).toHaveLength(creditAccounts.length)
|
||||||
|
for (const creditAccount of creditAccounts) {
|
||||||
|
const account = accounts.find((a) => a.tellerAccountId === creditAccount.id)!
|
||||||
|
expect(account.transactions).toHaveLength(
|
||||||
|
tellerTransactions.filter((t) => t.account_id === account.tellerAccountId).length
|
||||||
|
)
|
||||||
|
expect(account.holdings).toHaveLength(0)
|
||||||
|
expect(account.valuations).toHaveLength(0)
|
||||||
|
expect(account.investmentTransactions).toHaveLength(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// depository accounts
|
||||||
|
const depositoryAccounts = tellerAccounts.filter((a) => a.type === 'depository')
|
||||||
|
expect(accounts.filter((a) => a.type === 'DEPOSITORY')).toHaveLength(
|
||||||
|
depositoryAccounts.length
|
||||||
|
)
|
||||||
|
for (const depositoryAccount of depositoryAccounts) {
|
||||||
|
const account = accounts.find((a) => a.tellerAccountId === depositoryAccount.id)!
|
||||||
|
expect(account.transactions).toHaveLength(
|
||||||
|
tellerTransactions.filter((t) => t.account_id === account.tellerAccountId).length
|
||||||
|
)
|
||||||
|
expect(account.holdings).toHaveLength(0)
|
||||||
|
expect(account.valuations).toHaveLength(0)
|
||||||
|
expect(account.investmentTransactions).toHaveLength(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
|
@ -259,6 +259,7 @@ export const securityPricingProcessor: ISecurityPricingProcessor = new SecurityP
|
||||||
const institutionProviderFactory = new InstitutionProviderFactory({
|
const institutionProviderFactory = new InstitutionProviderFactory({
|
||||||
PLAID: plaidService,
|
PLAID: plaidService,
|
||||||
FINICITY: finicityService,
|
FINICITY: finicityService,
|
||||||
|
TELLER: tellerService,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const institutionService: IInstitutionService = new InstitutionService(
|
export const institutionService: IInstitutionService = new InstitutionService(
|
||||||
|
|
|
@ -22,8 +22,6 @@ const envSchema = z.object({
|
||||||
NX_SENTRY_DSN: z.string().optional(),
|
NX_SENTRY_DSN: z.string().optional(),
|
||||||
NX_SENTRY_ENV: z.string().optional(),
|
NX_SENTRY_ENV: z.string().optional(),
|
||||||
|
|
||||||
NX_LD_SDK_KEY: z.string().default('REPLACE_THIS'),
|
|
||||||
|
|
||||||
NX_REDIS_URL: z.string().default('redis://localhost:6379'),
|
NX_REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||||
|
|
||||||
NX_POLYGON_API_KEY: z.string().default(''),
|
NX_POLYGON_API_KEY: z.string().default(''),
|
||||||
|
@ -35,6 +33,8 @@ const envSchema = z.object({
|
||||||
|
|
||||||
NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'),
|
NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'),
|
||||||
NX_CDN_PUBLIC_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)
|
const env = envSchema.parse(process.env)
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {
|
||||||
workerErrorHandlerService,
|
workerErrorHandlerService,
|
||||||
} from './app/lib/di'
|
} from './app/lib/di'
|
||||||
import env from './env'
|
import env from './env'
|
||||||
|
import { cleanUpOutdatedJobs } from './utils'
|
||||||
|
|
||||||
// Defaults from quickstart - https://docs.sentry.io/platforms/node/
|
// Defaults from quickstart - https://docs.sentry.io/platforms/node/
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
|
@ -99,6 +100,7 @@ syncSecurityQueue.add(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
repeat: { cron: '*/5 * * * *' }, // Run every 5 minutes
|
repeat: { cron: '*/5 * * * *' }, // Run every 5 minutes
|
||||||
|
jobId: Date.now().toString(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -115,11 +117,17 @@ syncInstitutionQueue.process(
|
||||||
async () => await institutionService.sync('FINICITY')
|
async () => await institutionService.sync('FINICITY')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
syncInstitutionQueue.process(
|
||||||
|
'sync-teller-institutions',
|
||||||
|
async () => await institutionService.sync('TELLER')
|
||||||
|
)
|
||||||
|
|
||||||
syncInstitutionQueue.add(
|
syncInstitutionQueue.add(
|
||||||
'sync-plaid-institutions',
|
'sync-plaid-institutions',
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
|
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
|
||||||
|
jobId: Date.now().toString(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -128,6 +136,16 @@ syncInstitutionQueue.add(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
|
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
|
||||||
|
jobId: Date.now().toString(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
syncInstitutionQueue.add(
|
||||||
|
'sync-teller-institutions',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
repeat: { cron: '0 */24 * * *' }, // Run every 24 hours
|
||||||
|
jobId: Date.now().toString(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -136,11 +154,13 @@ syncInstitutionQueue.add(
|
||||||
*/
|
*/
|
||||||
sendEmailQueue.process('send-email', async (job) => await emailProcessor.send(job.data))
|
sendEmailQueue.process('send-email', async (job) => await emailProcessor.send(job.data))
|
||||||
|
|
||||||
sendEmailQueue.add(
|
if (env.STRIPE_API_KEY) {
|
||||||
'send-email',
|
sendEmailQueue.add(
|
||||||
{ type: 'trial-reminders' },
|
'send-email',
|
||||||
{ repeat: { cron: '0 */12 * * *' } } // Run every 12 hours
|
{ 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)
|
// Fallback - usually triggered by errors not handled (or thrown) within the Bull event handlers (see above)
|
||||||
process.on(
|
process.on(
|
||||||
|
@ -156,6 +176,11 @@ process.on(
|
||||||
await workerErrorHandlerService.handleWorkersError({ variant: 'unhandled', error })
|
await workerErrorHandlerService.handleWorkersError({ variant: 'unhandled', error })
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Replace any jobs that have changed cron schedules and ensures only
|
||||||
|
// one repeatable jobs for each type is running
|
||||||
|
const queues = [syncSecurityQueue, syncInstitutionQueue]
|
||||||
|
cleanUpOutdatedJobs(queues)
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
app.use(cors())
|
app.use(cors())
|
||||||
|
@ -181,20 +206,24 @@ const server = app.listen(env.NX_PORT, () => {
|
||||||
logger.info(`Worker health server started on port ${env.NX_PORT}`)
|
logger.info(`Worker health server started on port ${env.NX_PORT}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
function onShutdown() {
|
async function onShutdown() {
|
||||||
logger.info('[shutdown.start]')
|
logger.info('[shutdown.start]')
|
||||||
|
|
||||||
server.close()
|
await new Promise((resolve) => server.close(resolve))
|
||||||
|
|
||||||
// shutdown queues
|
// shutdown queues
|
||||||
Promise.allSettled(
|
try {
|
||||||
queueService.allQueues
|
await Promise.allSettled(
|
||||||
.filter((q): q is BullQueue => q instanceof BullQueue)
|
queueService.allQueues
|
||||||
.map((q) => q.queue.close())
|
.filter((q): q is BullQueue => q instanceof BullQueue)
|
||||||
).finally(() => {
|
.map((q) => q.queue.close())
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[shutdown.error]', error)
|
||||||
|
} finally {
|
||||||
logger.info('[shutdown.complete]')
|
logger.info('[shutdown.complete]')
|
||||||
process.exit()
|
process.exitCode = 0
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('SIGINT', onShutdown)
|
process.on('SIGINT', onShutdown)
|
||||||
|
|
46
apps/workers/src/utils.ts
Normal file
46
apps/workers/src/utils.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import type { IQueue } from '@maybe-finance/server/shared'
|
||||||
|
import type { JobInformation } from 'bull'
|
||||||
|
|
||||||
|
export async function cleanUpOutdatedJobs(queues: IQueue[]) {
|
||||||
|
for (const queue of queues) {
|
||||||
|
const repeatedJobs = await queue.getRepeatableJobs()
|
||||||
|
|
||||||
|
const outdatedJobs = filterOutdatedJobs(repeatedJobs)
|
||||||
|
for (const job of outdatedJobs) {
|
||||||
|
await queue.removeRepeatableByKey(job.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterOutdatedJobs(jobs: JobInformation[]) {
|
||||||
|
const jobGroups = new Map()
|
||||||
|
|
||||||
|
jobs.forEach((job) => {
|
||||||
|
if (!jobGroups.has(job.name)) {
|
||||||
|
jobGroups.set(job.name, [])
|
||||||
|
}
|
||||||
|
jobGroups.get(job.name).push(job)
|
||||||
|
})
|
||||||
|
|
||||||
|
const mostRecentJobs = new Map()
|
||||||
|
jobGroups.forEach((group, name) => {
|
||||||
|
const mostRecentJob = group.reduce((mostRecent, current) => {
|
||||||
|
if (current.id === null) return mostRecent
|
||||||
|
const currentIdTime = current.id
|
||||||
|
const mostRecentIdTime = mostRecent ? mostRecent.id : 0
|
||||||
|
|
||||||
|
return currentIdTime > mostRecentIdTime ? current : mostRecent
|
||||||
|
}, null)
|
||||||
|
|
||||||
|
if (mostRecentJob) {
|
||||||
|
mostRecentJobs.set(name, mostRecentJob.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return jobs.filter((job: JobInformation) => {
|
||||||
|
const mostRecentId = mostRecentJobs.get(job.name)
|
||||||
|
return job.id === null || job.id !== mostRecentId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default cleanUpOutdatedJobs
|
|
@ -31,7 +31,7 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- 4551:4551
|
- 4551:4551
|
||||||
environment:
|
environment:
|
||||||
- DOMAIN=host.docker.internal
|
- DOMAIN=${NGROK_DOMAIN:-host.docker.internal}
|
||||||
- PORT=3333
|
- PORT=3333
|
||||||
- AUTH_TOKEN=${NGROK_AUTH_TOKEN}
|
- AUTH_TOKEN=${NGROK_AUTH_TOKEN}
|
||||||
- DEBUG=true
|
- DEBUG=true
|
||||||
|
|
|
@ -7,12 +7,14 @@ import {
|
||||||
useDebounce,
|
useDebounce,
|
||||||
usePlaid,
|
usePlaid,
|
||||||
useFinicity,
|
useFinicity,
|
||||||
|
useTellerConfig,
|
||||||
|
useTellerConnect,
|
||||||
} from '@maybe-finance/client/shared'
|
} from '@maybe-finance/client/shared'
|
||||||
|
|
||||||
import { Input } from '@maybe-finance/design-system'
|
import { Input } from '@maybe-finance/design-system'
|
||||||
import InstitutionGrid from './InstitutionGrid'
|
import InstitutionGrid from './InstitutionGrid'
|
||||||
import { AccountTypeGrid } from './AccountTypeGrid'
|
import { AccountTypeGrid } from './AccountTypeGrid'
|
||||||
import InstitutionList, { MIN_QUERY_LENGTH } from './InstitutionList'
|
import InstitutionList, { MIN_QUERY_LENGTH } from './InstitutionList'
|
||||||
|
import { useLogger } from '@maybe-finance/client/shared'
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 300
|
const SEARCH_DEBOUNCE_MS = 300
|
||||||
|
|
||||||
|
@ -23,6 +25,7 @@ export default function AccountTypeSelector({
|
||||||
view: string
|
view: string
|
||||||
onViewChange: (view: string) => void
|
onViewChange: (view: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
const logger = useLogger()
|
||||||
const { setAccountManager } = useAccountContext()
|
const { setAccountManager } = useAccountContext()
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||||
|
@ -33,8 +36,11 @@ export default function AccountTypeSelector({
|
||||||
debouncedSearchQuery.length >= MIN_QUERY_LENGTH &&
|
debouncedSearchQuery.length >= MIN_QUERY_LENGTH &&
|
||||||
view !== 'manual'
|
view !== 'manual'
|
||||||
|
|
||||||
|
const config = useTellerConfig(logger)
|
||||||
|
|
||||||
const { openPlaid } = usePlaid()
|
const { openPlaid } = usePlaid()
|
||||||
const { openFinicity } = useFinicity()
|
const { openFinicity } = useFinicity()
|
||||||
|
const { open: openTeller } = useTellerConnect(config, logger)
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
@ -77,6 +83,9 @@ export default function AccountTypeSelector({
|
||||||
case 'FINICITY':
|
case 'FINICITY':
|
||||||
openFinicity(providerInstitution.providerId)
|
openFinicity(providerInstitution.providerId)
|
||||||
break
|
break
|
||||||
|
case 'TELLER':
|
||||||
|
openTeller(providerInstitution.providerId)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -138,11 +147,12 @@ export default function AccountTypeSelector({
|
||||||
categoryUser: 'crypto',
|
categoryUser: 'crypto',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) return
|
if (!data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch (data.provider) {
|
switch (data.provider) {
|
||||||
case 'PLAID':
|
case 'PLAID':
|
||||||
|
@ -151,6 +161,9 @@ export default function AccountTypeSelector({
|
||||||
case 'FINICITY':
|
case 'FINICITY':
|
||||||
openFinicity(data.providerId)
|
openFinicity(data.providerId)
|
||||||
break
|
break
|
||||||
|
case 'TELLER':
|
||||||
|
openTeller(data.providerId)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,48 +14,48 @@ const banks: GridImage[] = [
|
||||||
src: 'chase-bank.png',
|
src: 'chase-bank.png',
|
||||||
alt: 'Chase Bank',
|
alt: 'Chase Bank',
|
||||||
institution: {
|
institution: {
|
||||||
provider: 'PLAID',
|
provider: 'TELLER',
|
||||||
providerId: 'ins_56',
|
providerId: 'chase',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'capital-one.png',
|
src: 'capital-one.png',
|
||||||
alt: 'Capital One Bank',
|
alt: 'Capital One Bank',
|
||||||
institution: {
|
institution: {
|
||||||
provider: 'PLAID',
|
provider: 'TELLER',
|
||||||
providerId: 'ins_128026',
|
providerId: 'capital_one',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'wells-fargo.png',
|
src: 'wells-fargo.png',
|
||||||
alt: 'Wells Fargo Bank',
|
alt: 'Wells Fargo Bank',
|
||||||
institution: {
|
institution: {
|
||||||
provider: 'PLAID',
|
provider: 'TELLER',
|
||||||
providerId: 'ins_127991',
|
providerId: 'wells_fargo',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'american-express.png',
|
src: 'american-express.png',
|
||||||
alt: 'American Express Bank',
|
alt: 'American Express Bank',
|
||||||
institution: {
|
institution: {
|
||||||
provider: 'PLAID',
|
provider: 'TELLER',
|
||||||
providerId: 'ins_10',
|
providerId: 'amex',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'bofa.png',
|
src: 'bofa.png',
|
||||||
alt: 'Bank of America',
|
alt: 'Bank of America',
|
||||||
institution: {
|
institution: {
|
||||||
provider: 'PLAID',
|
provider: 'TELLER',
|
||||||
providerId: 'ins_127989',
|
providerId: 'bank_of_america',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'usaa-bank.png',
|
src: 'usaa-bank.png',
|
||||||
alt: 'USAA Bank',
|
alt: 'USAA Bank',
|
||||||
institution: {
|
institution: {
|
||||||
provider: 'PLAID',
|
provider: 'TELLER',
|
||||||
providerId: 'ins_7',
|
providerId: 'usaa',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
@ -19,6 +19,7 @@ export function AddProperty({ defaultValues }: { defaultValues: Partial<CreatePr
|
||||||
line1: defaultValues.line1 ?? '',
|
line1: defaultValues.line1 ?? '',
|
||||||
city: defaultValues.city ?? '',
|
city: defaultValues.city ?? '',
|
||||||
state: defaultValues.state ?? '',
|
state: defaultValues.state ?? '',
|
||||||
|
country: defaultValues.country ?? 'US',
|
||||||
zip: defaultValues.zip ?? '',
|
zip: defaultValues.zip ?? '',
|
||||||
startDate: defaultValues.startDate ?? null,
|
startDate: defaultValues.startDate ?? null,
|
||||||
originalBalance: defaultValues.originalBalance ?? null,
|
originalBalance: defaultValues.originalBalance ?? null,
|
||||||
|
@ -28,6 +29,7 @@ export function AddProperty({ defaultValues }: { defaultValues: Partial<CreatePr
|
||||||
line1,
|
line1,
|
||||||
city,
|
city,
|
||||||
state,
|
state,
|
||||||
|
country,
|
||||||
zip,
|
zip,
|
||||||
originalBalance,
|
originalBalance,
|
||||||
currentBalance,
|
currentBalance,
|
||||||
|
@ -48,6 +50,7 @@ export function AddProperty({ defaultValues }: { defaultValues: Partial<CreatePr
|
||||||
line1,
|
line1,
|
||||||
city,
|
city,
|
||||||
state,
|
state,
|
||||||
|
country,
|
||||||
zip,
|
zip,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -16,7 +16,7 @@ export function EditProperty({ account }: { account: SharedType.AccountDetail })
|
||||||
<PropertyForm
|
<PropertyForm
|
||||||
mode="update"
|
mode="update"
|
||||||
defaultValues={(account.propertyMeta as any)?.address as UpdatePropertyFields}
|
defaultValues={(account.propertyMeta as any)?.address as UpdatePropertyFields}
|
||||||
onSubmit={async ({ line1, city, state, zip, ...rest }) => {
|
onSubmit={async ({ line1, city, state, country, zip, ...rest }) => {
|
||||||
await updateAccount.mutateAsync({
|
await updateAccount.mutateAsync({
|
||||||
id: account.id,
|
id: account.id,
|
||||||
data: {
|
data: {
|
||||||
|
@ -30,6 +30,7 @@ export function EditProperty({ account }: { account: SharedType.AccountDetail })
|
||||||
line1,
|
line1,
|
||||||
city,
|
city,
|
||||||
state,
|
state,
|
||||||
|
country,
|
||||||
zip,
|
zip,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { CreatePropertyFields, UpdatePropertyFields } from '@maybe-finance/client/shared'
|
import type { CreatePropertyFields, UpdatePropertyFields } from '@maybe-finance/client/shared'
|
||||||
import { Button, Input } from '@maybe-finance/design-system'
|
import { Button, Input, Listbox } from '@maybe-finance/design-system'
|
||||||
import { DateUtil } from '@maybe-finance/shared'
|
import { DateUtil, Geo } from '@maybe-finance/shared'
|
||||||
import { useForm } from 'react-hook-form'
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
import { AccountValuationFormFields } from '../AccountValuationFormFields'
|
import { AccountValuationFormFields } from '../AccountValuationFormFields'
|
||||||
|
|
||||||
type Props =
|
type Props =
|
||||||
|
@ -36,7 +36,26 @@ export default function PropertyForm({ mode, defaultValues, onSubmit }: Props) {
|
||||||
<section className="space-y-4 mb-8">
|
<section className="space-y-4 mb-8">
|
||||||
<h6 className="text-white uppercase">Location</h6>
|
<h6 className="text-white uppercase">Location</h6>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Input type="text" label="Country" value="United States" readOnly disabled />
|
<Controller
|
||||||
|
name="country"
|
||||||
|
rules={{ required: true }}
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Listbox {...field}>
|
||||||
|
<Listbox.Button label="Country">
|
||||||
|
{Geo.countries.find((c) => c.code === field.value)?.name ||
|
||||||
|
'Select'}
|
||||||
|
</Listbox.Button>
|
||||||
|
<Listbox.Options className="max-h-[300px] custom-gray-scroll">
|
||||||
|
{Geo.countries.map((country) => (
|
||||||
|
<Listbox.Option key={country.code} value={country.code}>
|
||||||
|
{country.name}
|
||||||
|
</Listbox.Option>
|
||||||
|
))}
|
||||||
|
</Listbox.Options>
|
||||||
|
</Listbox>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
|
@ -313,7 +313,6 @@ function DefaultContent({
|
||||||
email,
|
email,
|
||||||
}: PropsWithChildren<{ onboarding?: ReactNode; name?: string; email?: string }>) {
|
}: PropsWithChildren<{ onboarding?: ReactNode; name?: string; email?: string }>) {
|
||||||
const { addAccount } = useAccountContext()
|
const { addAccount } = useAccountContext()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
@ -338,7 +337,7 @@ function DefaultContent({
|
||||||
|
|
||||||
{onboarding && onboarding}
|
{onboarding && onboarding}
|
||||||
|
|
||||||
<UpgradePrompt />
|
{process.env.STRIPE_API_KEY && <UpgradePrompt />}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-base">
|
<div className="text-base">
|
||||||
|
|
|
@ -174,7 +174,7 @@ export function MobileLayout({ children, sidebar }: MobileLayoutProps) {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="pt-6 shrink-0">
|
<div className="pt-6 shrink-0">
|
||||||
<UpgradePrompt />
|
{process.env.STRIPE_API_KEY && <UpgradePrompt />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -78,7 +78,9 @@ export function BillingPreferences() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<UpgradeTakeover open={takeoverOpen} onClose={() => setTakeoverOpen(false)} />
|
{process.env.STRIPE_API_KEY && (
|
||||||
|
<UpgradeTakeover open={takeoverOpen} onClose={() => setTakeoverOpen(false)} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ export * from './useFinicityApi'
|
||||||
export * from './useInstitutionApi'
|
export * from './useInstitutionApi'
|
||||||
export * from './useUserApi'
|
export * from './useUserApi'
|
||||||
export * from './usePlaidApi'
|
export * from './usePlaidApi'
|
||||||
|
export * from './useTellerApi'
|
||||||
export * from './useValuationApi'
|
export * from './useValuationApi'
|
||||||
export * from './useTransactionApi'
|
export * from './useTransactionApi'
|
||||||
export * from './useHoldingApi'
|
export * from './useHoldingApi'
|
||||||
|
|
64
libs/client/shared/src/api/useTellerApi.ts
Normal file
64
libs/client/shared/src/api/useTellerApi.ts
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
|
import type { AxiosInstance } from 'axios'
|
||||||
|
import type { TellerTypes } from '@maybe-finance/teller-api'
|
||||||
|
import { useAccountConnectionApi } from './useAccountConnectionApi'
|
||||||
|
|
||||||
|
type TellerInstitution = {
|
||||||
|
name: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TellerApi = (axios: AxiosInstance) => ({
|
||||||
|
async handleEnrollment(input: {
|
||||||
|
institution: TellerInstitution
|
||||||
|
enrollment: TellerTypes.Enrollment
|
||||||
|
}) {
|
||||||
|
const { data } = await axios.post<SharedType.AccountConnection>(
|
||||||
|
'/teller/handle-enrollment',
|
||||||
|
input
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useTellerApi() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { axios } = useAxiosWithAuth()
|
||||||
|
const api = useMemo(() => TellerApi(axios), [axios])
|
||||||
|
|
||||||
|
const { useSyncConnection } = useAccountConnectionApi()
|
||||||
|
const syncConnection = useSyncConnection()
|
||||||
|
|
||||||
|
const addConnectionToState = (connection: SharedType.AccountConnection) => {
|
||||||
|
const accountsData = queryClient.getQueryData<SharedType.AccountsResponse>(['accounts'])
|
||||||
|
if (!accountsData)
|
||||||
|
queryClient.setQueryData<SharedType.AccountsResponse>(['accounts'], {
|
||||||
|
connections: [{ ...connection, accounts: [] }],
|
||||||
|
accounts: [],
|
||||||
|
})
|
||||||
|
else {
|
||||||
|
const { connections, ...rest } = accountsData
|
||||||
|
queryClient.setQueryData<SharedType.AccountsResponse>(['accounts'], {
|
||||||
|
connections: [...connections, { ...connection, accounts: [] }],
|
||||||
|
...rest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useHandleEnrollment = () =>
|
||||||
|
useMutation(api.handleEnrollment, {
|
||||||
|
onSuccess: (_connection) => {
|
||||||
|
addConnectionToState(_connection)
|
||||||
|
syncConnection.mutate(_connection.id)
|
||||||
|
toast.success(`Account connection added!`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
useHandleEnrollment,
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,5 +9,6 @@ export * from './useQueryParam'
|
||||||
export * from './useScreenSize'
|
export * from './useScreenSize'
|
||||||
export * from './useAccountNotifications'
|
export * from './useAccountNotifications'
|
||||||
export * from './usePlaid'
|
export * from './usePlaid'
|
||||||
|
export * from './useTeller'
|
||||||
export * from './useProviderStatus'
|
export * from './useProviderStatus'
|
||||||
export * from './useModalManager'
|
export * from './useModalManager'
|
||||||
|
|
182
libs/client/shared/src/hooks/useTeller.ts
Normal file
182
libs/client/shared/src/hooks/useTeller.ts
Normal file
|
@ -0,0 +1,182 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import * as Sentry from '@sentry/react'
|
||||||
|
import type { Logger } from '../providers/LogProvider'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { useAccountContext } from '../providers'
|
||||||
|
import { useTellerApi } from '../api'
|
||||||
|
import type {
|
||||||
|
TellerConnectEnrollment,
|
||||||
|
TellerConnectFailure,
|
||||||
|
TellerConnectOptions,
|
||||||
|
TellerConnectInstance,
|
||||||
|
} from 'teller-connect-react'
|
||||||
|
import useScript from 'react-script-hook'
|
||||||
|
type TellerEnvironment = 'sandbox' | 'development' | 'production' | undefined
|
||||||
|
type TellerAccountSelection = 'disabled' | 'single' | 'multiple' | undefined
|
||||||
|
const TC_JS = 'https://cdn.teller.io/connect/connect.js'
|
||||||
|
|
||||||
|
// Create the base configuration for Teller Connect
|
||||||
|
export const useTellerConfig = (logger: Logger) => {
|
||||||
|
return {
|
||||||
|
applicationId: process.env.NEXT_PUBLIC_TELLER_APP_ID ?? 'ADD_TELLER_APP_ID',
|
||||||
|
environment: (process.env.NEXT_PUBLIC_TELLER_ENV as TellerEnvironment) ?? 'sandbox',
|
||||||
|
selectAccount: 'disabled' as TellerAccountSelection,
|
||||||
|
onInit: () => {
|
||||||
|
logger.debug(`Teller Connect has initialized`)
|
||||||
|
},
|
||||||
|
onSuccess: {},
|
||||||
|
onExit: () => {
|
||||||
|
logger.debug(`Teller Connect exited`)
|
||||||
|
},
|
||||||
|
onFailure: (failure: TellerConnectFailure) => {
|
||||||
|
logger.error(`Teller Connect exited with error`, failure)
|
||||||
|
Sentry.captureEvent({
|
||||||
|
level: 'error',
|
||||||
|
message: 'TELLER_CONNECT_ERROR',
|
||||||
|
tags: {
|
||||||
|
'teller.error.code': failure.code,
|
||||||
|
'teller.error.message': failure.message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} as TellerConnectOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom implementation of useTellerHook to handle institution id being passed in
|
||||||
|
export const useTellerConnect = (options: TellerConnectOptions, logger: Logger) => {
|
||||||
|
const { useHandleEnrollment } = useTellerApi()
|
||||||
|
const handleEnrollment = useHandleEnrollment()
|
||||||
|
const { setAccountManager } = useAccountContext()
|
||||||
|
const [loading, error] = useScript({
|
||||||
|
src: TC_JS,
|
||||||
|
checkForExisting: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [teller, setTeller] = useState<TellerConnectInstance | null>(null)
|
||||||
|
const [iframeLoaded, setIframeLoaded] = useState(false)
|
||||||
|
|
||||||
|
const createTellerInstance = (institutionId: string) => {
|
||||||
|
return createTeller(
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
onSuccess: async (enrollment: TellerConnectEnrollment) => {
|
||||||
|
logger.debug('User enrolled successfully')
|
||||||
|
try {
|
||||||
|
await handleEnrollment.mutateAsync({
|
||||||
|
institution: {
|
||||||
|
id: institutionId!,
|
||||||
|
name: enrollment.enrollment.institution.name,
|
||||||
|
},
|
||||||
|
enrollment,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`Failed to add account`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
institution: institutionId,
|
||||||
|
onInit: () => {
|
||||||
|
setIframeLoaded(true)
|
||||||
|
options.onInit && options.onInit()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
window.TellerConnect.setup
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.applicationId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !window.TellerConnect) {
|
||||||
|
console.error('Error loading TellerConnect:', error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (teller != null) {
|
||||||
|
teller.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => teller?.destroy()
|
||||||
|
}, [
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
options.applicationId,
|
||||||
|
options.enrollmentId,
|
||||||
|
options.connectToken,
|
||||||
|
options.products,
|
||||||
|
])
|
||||||
|
|
||||||
|
const ready = teller != null && (!loading || iframeLoaded)
|
||||||
|
|
||||||
|
const logIt = () => {
|
||||||
|
if (!options.applicationId) {
|
||||||
|
console.error('teller-connect-react: open() called without a valid applicationId.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error,
|
||||||
|
ready,
|
||||||
|
open: (institutionId: string) => {
|
||||||
|
logIt()
|
||||||
|
const tellerInstance = createTellerInstance(institutionId)
|
||||||
|
tellerInstance.open()
|
||||||
|
setAccountManager({ view: 'idle' })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManagerState {
|
||||||
|
teller: TellerConnectInstance | null
|
||||||
|
open: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTeller = (
|
||||||
|
config: TellerConnectOptions,
|
||||||
|
creator: (config: TellerConnectOptions) => TellerConnectInstance
|
||||||
|
) => {
|
||||||
|
const state: ManagerState = {
|
||||||
|
teller: null,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window === 'undefined' || !window.TellerConnect) {
|
||||||
|
throw new Error('TellerConnect is not loaded')
|
||||||
|
}
|
||||||
|
|
||||||
|
state.teller = creator({
|
||||||
|
...config,
|
||||||
|
onExit: () => {
|
||||||
|
state.open = false
|
||||||
|
config.onExit && config.onExit()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
if (!state.teller) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.open = true
|
||||||
|
state.teller.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
if (!state.teller) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.teller.destroy()
|
||||||
|
state.teller = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
destroy,
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ type PropertyMetadataValues = {
|
||||||
line1: string
|
line1: string
|
||||||
city: string
|
city: string
|
||||||
state: string
|
state: string
|
||||||
|
country: string
|
||||||
zip: string
|
zip: string
|
||||||
}
|
}
|
||||||
export type CreatePropertyFields = PropertyMetadataValues & AccountValuationFields
|
export type CreatePropertyFields = PropertyMetadataValues & AccountValuationFields
|
||||||
|
@ -50,6 +51,7 @@ type AccountManager =
|
||||||
| { view: 'idle' }
|
| { view: 'idle' }
|
||||||
| { view: 'add-plaid'; linkToken: string }
|
| { view: 'add-plaid'; linkToken: string }
|
||||||
| { view: 'add-finicity' }
|
| { view: 'add-finicity' }
|
||||||
|
| { view: 'add-teller' }
|
||||||
| { view: 'add-account' }
|
| { view: 'add-account' }
|
||||||
| { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> }
|
| { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> }
|
||||||
| { view: 'add-vehicle'; defaultValues: Partial<CreateVehicleFields> }
|
| { view: 'add-vehicle'; defaultValues: Partial<CreateVehicleFields> }
|
||||||
|
|
|
@ -3,9 +3,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { DatePicker } from './'
|
import { DatePicker } from './'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
|
|
||||||
// Set date to Oct 29, 2021 to keep snapshots consistent
|
|
||||||
beforeAll(() => jest.useFakeTimers().setSystemTime(new Date('2021-10-29 12:00:00')))
|
|
||||||
|
|
||||||
// DatePicker configuration
|
// DatePicker configuration
|
||||||
const minDate = DateTime.now().minus({ years: 2 })
|
const minDate = DateTime.now().minus({ years: 2 })
|
||||||
const maxDate = DateTime.now()
|
const maxDate = DateTime.now()
|
||||||
|
|
|
@ -73,7 +73,7 @@ export function DatePickerCalendar({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="text-white hover:bg-gray-500 flex grow justify-center items-center rounded"
|
className="text-white hover:bg-gray-500 flex grow justify-center items-center rounded"
|
||||||
data-testid="datepicker-range-month-button"
|
data-testid="datepicker-range-year-button"
|
||||||
onClick={() => setView('year')}
|
onClick={() => setView('year')}
|
||||||
>
|
>
|
||||||
{calendars[0].year}
|
{calendars[0].year}
|
||||||
|
|
|
@ -103,7 +103,7 @@ export function DatePickerRangeCalendar({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="text-white hover:bg-gray-500 flex grow justify-center items-center rounded"
|
className="text-white hover:bg-gray-500 flex grow justify-center items-center rounded"
|
||||||
data-testid="datepicker-range-month-button"
|
data-testid="datepicker-range-year-button"
|
||||||
onClick={() => setView('year')}
|
onClick={() => setView('year')}
|
||||||
>
|
>
|
||||||
{calendars[0].year}
|
{calendars[0].year}
|
||||||
|
|
|
@ -271,7 +271,7 @@ export class InstitutionService implements IInstitutionService {
|
||||||
provider_institution pi
|
provider_institution pi
|
||||||
SET
|
SET
|
||||||
institution_id = i.id,
|
institution_id = i.id,
|
||||||
rank = (CASE WHEN pi.provider = 'PLAID' THEN 1 ELSE 0 END)
|
rank = (CASE WHEN pi.provider = 'TELLER' THEN 1 ELSE 0 END)
|
||||||
FROM
|
FROM
|
||||||
duplicates d
|
duplicates d
|
||||||
INNER JOIN institutions i ON i.name = d.name AND i.url = d.url
|
INNER JOIN institutions i ON i.name = d.name AND i.url = d.url
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { AccountConnection, PrismaClient } from '@prisma/client'
|
import type { AccountConnection, PrismaClient } from '@prisma/client'
|
||||||
import type { Logger } from 'winston'
|
import type { Logger } from 'winston'
|
||||||
import { SharedUtil, AccountUtil, type SharedType } from '@maybe-finance/shared'
|
import { AccountUtil, SharedUtil, type SharedType } from '@maybe-finance/shared'
|
||||||
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
|
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
|
||||||
import { DbUtil, TellerUtil, type IETL, type ICryptoService } from '@maybe-finance/server/shared'
|
import { DbUtil, TellerUtil, type IETL, type ICryptoService } from '@maybe-finance/server/shared'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
|
@ -101,16 +101,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
|
|
||||||
private async _extractAccounts(accessToken: string) {
|
private async _extractAccounts(accessToken: string) {
|
||||||
const accounts = await this.teller.getAccounts({ accessToken })
|
const accounts = await this.teller.getAccounts({ accessToken })
|
||||||
const accountsWithBalances = await Promise.all(
|
return accounts
|
||||||
accounts.map(async (a) => {
|
|
||||||
const balance = await this.teller.getAccountBalances({
|
|
||||||
accountId: a.id,
|
|
||||||
accessToken,
|
|
||||||
})
|
|
||||||
return { ...a, balance }
|
|
||||||
})
|
|
||||||
)
|
|
||||||
return accountsWithBalances
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _loadAccounts(connection: Connection, { accounts }: Pick<TellerData, 'accounts'>) {
|
private _loadAccounts(connection: Connection, { accounts }: Pick<TellerData, 'accounts'>) {
|
||||||
|
@ -119,6 +110,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
...accounts.map((tellerAccount) => {
|
...accounts.map((tellerAccount) => {
|
||||||
const type = TellerUtil.getType(tellerAccount.type)
|
const type = TellerUtil.getType(tellerAccount.type)
|
||||||
const classification = AccountUtil.getClassification(type)
|
const classification = AccountUtil.getClassification(type)
|
||||||
|
|
||||||
return this.prisma.account.upsert({
|
return this.prisma.account.upsert({
|
||||||
where: {
|
where: {
|
||||||
accountConnectionId_tellerAccountId: {
|
accountConnectionId_tellerAccountId: {
|
||||||
|
@ -132,6 +124,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type),
|
categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type),
|
||||||
subcategoryProvider: tellerAccount.subtype ?? 'other',
|
subcategoryProvider: tellerAccount.subtype ?? 'other',
|
||||||
accountConnectionId: connection.id,
|
accountConnectionId: connection.id,
|
||||||
|
userId: connection.userId,
|
||||||
tellerAccountId: tellerAccount.id,
|
tellerAccountId: tellerAccount.id,
|
||||||
name: tellerAccount.name,
|
name: tellerAccount.name,
|
||||||
tellerType: tellerAccount.type,
|
tellerType: tellerAccount.type,
|
||||||
|
@ -170,27 +163,21 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
|
|
||||||
private async _extractTransactions(accessToken: string, accountIds: string[]) {
|
private async _extractTransactions(accessToken: string, accountIds: string[]) {
|
||||||
const accountTransactions = await Promise.all(
|
const accountTransactions = await Promise.all(
|
||||||
accountIds.map((accountId) =>
|
accountIds.map(async (accountId) => {
|
||||||
SharedUtil.paginate({
|
const transactions = await SharedUtil.withRetry(
|
||||||
pageSize: 1000, // TODO: Check with Teller on max page size
|
() =>
|
||||||
fetchData: async () => {
|
this.teller.getTransactions({
|
||||||
const transactions = await SharedUtil.withRetry(
|
accountId,
|
||||||
() =>
|
accessToken,
|
||||||
this.teller.getTransactions({
|
}),
|
||||||
accountId,
|
{
|
||||||
accessToken: accessToken,
|
maxRetries: 3,
|
||||||
}),
|
}
|
||||||
{
|
)
|
||||||
maxRetries: 3,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return transactions
|
return transactions
|
||||||
},
|
})
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return accountTransactions.flat()
|
return accountTransactions.flat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -210,7 +197,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
${Prisma.join(
|
${Prisma.join(
|
||||||
chunk.map((tellerTransaction) => {
|
chunk.map((tellerTransaction) => {
|
||||||
const {
|
const {
|
||||||
id,
|
id: transactionId,
|
||||||
account_id,
|
account_id,
|
||||||
description,
|
description,
|
||||||
amount,
|
amount,
|
||||||
|
@ -224,15 +211,15 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
(SELECT id FROM account WHERE account_connection_id = ${
|
(SELECT id FROM account WHERE account_connection_id = ${
|
||||||
connection.id
|
connection.id
|
||||||
} AND teller_account_id = ${account_id.toString()}),
|
} AND teller_account_id = ${account_id.toString()}),
|
||||||
${id},
|
${transactionId},
|
||||||
${date}::date,
|
${date}::date,
|
||||||
${[description].filter(Boolean).join(' ')},
|
${description},
|
||||||
${DbUtil.toDecimal(-amount)},
|
${DbUtil.toDecimal(Number(amount))},
|
||||||
${status === 'pending'},
|
${status === 'pending'},
|
||||||
${'USD'},
|
${'USD'},
|
||||||
${details.counterparty.name ?? ''},
|
${details.counterparty?.name ?? ''},
|
||||||
${type},
|
${type},
|
||||||
${details.category ?? ''},
|
${details.category ?? ''}
|
||||||
)`
|
)`
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -6,19 +6,11 @@ import type {
|
||||||
IAccountConnectionProvider,
|
IAccountConnectionProvider,
|
||||||
} from '../../account-connection'
|
} from '../../account-connection'
|
||||||
import { SharedUtil } from '@maybe-finance/shared'
|
import { SharedUtil } from '@maybe-finance/shared'
|
||||||
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
import type { SyncConnectionOptions, CryptoService, IETL } from '@maybe-finance/server/shared'
|
import type { SyncConnectionOptions, CryptoService, IETL } from '@maybe-finance/server/shared'
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
import { ErrorUtil, etl } from '@maybe-finance/server/shared'
|
import { ErrorUtil, etl } from '@maybe-finance/server/shared'
|
||||||
import type { TellerApi } from '@maybe-finance/teller-api'
|
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
|
||||||
|
|
||||||
export interface ITellerConnect {
|
|
||||||
generateConnectUrl(userId: User['id'], institutionId: string): Promise<{ link: string }>
|
|
||||||
|
|
||||||
generateFixConnectUrl(
|
|
||||||
userId: User['id'],
|
|
||||||
accountConnectionId: AccountConnection['id']
|
|
||||||
): Promise<{ link: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TellerService implements IAccountConnectionProvider, IInstitutionProvider {
|
export class TellerService implements IAccountConnectionProvider, IInstitutionProvider {
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -44,6 +36,7 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
|
||||||
where: { id: connection.id },
|
where: { id: connection.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'OK',
|
status: 'OK',
|
||||||
|
syncStatus: 'IDLE',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
@ -67,19 +60,28 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
|
||||||
|
|
||||||
async delete(connection: AccountConnection) {
|
async delete(connection: AccountConnection) {
|
||||||
// purge teller data
|
// purge teller data
|
||||||
if (connection.tellerAccessToken && connection.tellerAccountId) {
|
if (connection.tellerAccessToken && connection.tellerEnrollmentId) {
|
||||||
await this.teller.deleteAccount({
|
const accounts = await this.prisma.account.findMany({
|
||||||
accessToken: this.crypto.decrypt(connection.tellerAccessToken),
|
where: { accountConnectionId: connection.id },
|
||||||
accountId: connection.tellerAccountId,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.logger.info(`Item ${connection.tellerAccountId} removed`)
|
for (const account of accounts) {
|
||||||
|
if (!account.tellerAccountId) continue
|
||||||
|
await this.teller.deleteAccount({
|
||||||
|
accessToken: this.crypto.decrypt(connection.tellerAccessToken),
|
||||||
|
accountId: account.tellerAccountId,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.logger.info(`Teller account ${account.id} removed`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(`Teller enrollment ${connection.tellerEnrollmentId} removed`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInstitutions() {
|
async getInstitutions() {
|
||||||
const tellerInstitutions = await SharedUtil.paginate({
|
const tellerInstitutions = await SharedUtil.paginate({
|
||||||
pageSize: 500,
|
pageSize: 10000,
|
||||||
delay:
|
delay:
|
||||||
process.env.NODE_ENV !== 'production'
|
process.env.NODE_ENV !== 'production'
|
||||||
? {
|
? {
|
||||||
|
@ -87,20 +89,20 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
|
||||||
milliseconds: 7_000, // Sandbox rate limited at 10 calls / minute
|
milliseconds: 7_000, // Sandbox rate limited at 10 calls / minute
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
fetchData: (offset, count) =>
|
fetchData: () =>
|
||||||
SharedUtil.withRetry(
|
SharedUtil.withRetry(
|
||||||
() =>
|
() =>
|
||||||
this.teller.getInstitutions().then((data) => {
|
this.teller.getInstitutions().then((data) => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`paginated teller fetch inst=${data.institutions.length} (total=${data.institutions.length} offset=${offset} count=${count})`
|
`teller fetch inst=${data.length} (total=${data.length})`
|
||||||
)
|
)
|
||||||
return data.institutions
|
return data
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
onError: (error, attempt) => {
|
onError: (error, attempt) => {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Teller fetch institutions request failed attempt=${attempt} offset=${offset} count=${count}`,
|
`Teller fetch institutions request failed attempt=${attempt}`,
|
||||||
{ error: ErrorUtil.parseError(error) }
|
{ error: ErrorUtil.parseError(error) }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -115,12 +117,57 @@ export class TellerService implements IAccountConnectionProvider, IInstitutionPr
|
||||||
return {
|
return {
|
||||||
providerId: id,
|
providerId: id,
|
||||||
name,
|
name,
|
||||||
url: undefined,
|
url: null,
|
||||||
logo: `https://teller.io/images/banks/${id}.jpg}`,
|
logo: null,
|
||||||
primaryColor: undefined,
|
logoUrl: `https://teller.io/images/banks/${id}.jpg`,
|
||||||
oauth: undefined,
|
primaryColor: null,
|
||||||
|
oauth: false,
|
||||||
data: tellerInstitution,
|
data: tellerInstitution,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleEnrollment(
|
||||||
|
userId: User['id'],
|
||||||
|
institution: Pick<TellerTypes.Institution, 'name' | 'id'>,
|
||||||
|
enrollment: TellerTypes.Enrollment
|
||||||
|
) {
|
||||||
|
const connections = await this.prisma.accountConnection.findMany({
|
||||||
|
where: { userId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (connections.length > 40) {
|
||||||
|
throw new Error('MAX_ACCOUNT_CONNECTIONS')
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await this.teller.getAccounts({ accessToken: enrollment.accessToken })
|
||||||
|
|
||||||
|
this.logger.info(`Teller accounts retrieved for enrollment ${enrollment.enrollment.id}`)
|
||||||
|
|
||||||
|
// If all the accounts are Non-USD, throw an error
|
||||||
|
if (accounts.every((a) => a.currency !== 'USD')) {
|
||||||
|
throw new Error('USD_ONLY')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
tellerUserId: enrollment.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const accountConnection = await this.prisma.accountConnection.create({
|
||||||
|
data: {
|
||||||
|
name: enrollment.enrollment.institution.name,
|
||||||
|
type: 'teller' as SharedType.AccountConnectionType,
|
||||||
|
tellerEnrollmentId: enrollment.enrollment.id,
|
||||||
|
tellerInstitutionId: institution.id,
|
||||||
|
tellerAccessToken: this.crypto.encrypt(enrollment.accessToken),
|
||||||
|
userId,
|
||||||
|
syncStatus: 'PENDING',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return accountConnection
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import crypto from 'crypto'
|
import CryptoJS from 'crypto-js'
|
||||||
|
|
||||||
export interface ICryptoService {
|
export interface ICryptoService {
|
||||||
encrypt(plainText: string): string
|
encrypt(plainText: string): string
|
||||||
|
@ -6,32 +6,13 @@ export interface ICryptoService {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CryptoService implements ICryptoService {
|
export class CryptoService implements ICryptoService {
|
||||||
private key: Buffer
|
constructor(private readonly secret: string) {}
|
||||||
private ivLength = 16 // Initialization vector length. For AES, this is always 16
|
|
||||||
|
|
||||||
constructor(private readonly secret: string) {
|
|
||||||
// Ensure the key length is suitable for AES-256
|
|
||||||
this.key = crypto.createHash('sha256').update(String(this.secret)).digest()
|
|
||||||
}
|
|
||||||
|
|
||||||
encrypt(plainText: string) {
|
encrypt(plainText: string) {
|
||||||
const iv = crypto.randomBytes(this.ivLength)
|
return CryptoJS.AES.encrypt(plainText, this.secret).toString()
|
||||||
const cipher = crypto.createCipheriv('aes-256-cbc', this.key, iv)
|
|
||||||
let encrypted = cipher.update(plainText, 'utf8', 'hex')
|
|
||||||
encrypted += cipher.final('hex')
|
|
||||||
|
|
||||||
// Include the IV at the start of the encrypted result
|
|
||||||
return iv.toString('hex') + ':' + encrypted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
decrypt(encrypted: string) {
|
decrypt(encrypted: string) {
|
||||||
const textParts = encrypted.split(':')
|
return CryptoJS.AES.decrypt(encrypted, this.secret).toString(CryptoJS.enc.Utf8)
|
||||||
const iv = Buffer.from(textParts.shift()!, 'hex')
|
|
||||||
const encryptedText = textParts.join(':')
|
|
||||||
const decipher = crypto.createDecipheriv('aes-256-cbc', this.key, iv)
|
|
||||||
let decrypted = decipher.update(encryptedText, 'hex', 'utf8')
|
|
||||||
decrypted += decipher.final('utf8')
|
|
||||||
|
|
||||||
return decrypted
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { AccountConnection, User, Account } from '@prisma/client'
|
import type { AccountConnection, User, Account } from '@prisma/client'
|
||||||
import type { Logger } from 'winston'
|
import type { Logger } from 'winston'
|
||||||
import type { Job, JobOptions } from 'bull'
|
import type { Job, JobOptions, JobInformation } from 'bull'
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
|
|
||||||
export type IJob<T> = Pick<Job<T>, 'id' | 'name' | 'data' | 'progress'>
|
export type IJob<T> = Pick<Job<T>, 'id' | 'name' | 'data' | 'progress'>
|
||||||
|
@ -20,6 +20,8 @@ export type IQueue<TData extends Record<string, any> = {}, TJobName extends stri
|
||||||
options?: { concurrency: number }
|
options?: { concurrency: number }
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
getActiveJobs(): Promise<IJob<TData>[]>
|
getActiveJobs(): Promise<IJob<TData>[]>
|
||||||
|
getRepeatableJobs(): Promise<JobInformation[]>
|
||||||
|
removeRepeatableByKey(key: string): Promise<void>
|
||||||
cancelJobs(): Promise<void>
|
cancelJobs(): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,7 +72,7 @@ export type SyncSecurityQueue = IQueue<SyncSecurityQueueJobData, 'sync-all-secur
|
||||||
export type PurgeUserQueue = IQueue<{ userId: User['id'] }, 'purge-user'>
|
export type PurgeUserQueue = IQueue<{ userId: User['id'] }, 'purge-user'>
|
||||||
export type SyncInstitutionQueue = IQueue<
|
export type SyncInstitutionQueue = IQueue<
|
||||||
{},
|
{},
|
||||||
'sync-finicity-institutions' | 'sync-plaid-institutions'
|
'sync-finicity-institutions' | 'sync-plaid-institutions' | 'sync-teller-institutions'
|
||||||
>
|
>
|
||||||
export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>
|
export type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>
|
||||||
|
|
||||||
|
|
|
@ -120,6 +120,14 @@ export class BullQueue<TData extends Record<string, any> = any, TJobName extends
|
||||||
return this.queue.getActive()
|
return this.queue.getActive()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRepeatableJobs(): Promise<Queue.JobInformation[]> {
|
||||||
|
return this.queue.getRepeatableJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeRepeatableByKey(key: string) {
|
||||||
|
return this.queue.removeRepeatableByKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
async cancelJobs() {
|
async cancelJobs() {
|
||||||
await this.queue.pause(true, true)
|
await this.queue.pause(true, true)
|
||||||
await this.queue.removeJobs('*')
|
await this.queue.removeJobs('*')
|
||||||
|
|
|
@ -53,6 +53,14 @@ export class InMemoryQueue<
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRepeatableJobs() {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeRepeatableByKey(_key: string) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
async cancelJobs() {
|
async cancelJobs() {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,8 @@ import {
|
||||||
Prisma,
|
Prisma,
|
||||||
AccountCategory,
|
AccountCategory,
|
||||||
AccountType,
|
AccountType,
|
||||||
type AccountClassification,
|
|
||||||
type Account,
|
type Account,
|
||||||
|
type AccountClassification,
|
||||||
} from '@prisma/client'
|
} from '@prisma/client'
|
||||||
import type { TellerTypes } from '@maybe-finance/teller-api'
|
import type { TellerTypes } from '@maybe-finance/teller-api'
|
||||||
import { Duration } from 'luxon'
|
import { Duration } from 'luxon'
|
||||||
|
@ -11,10 +11,10 @@ import { Duration } from 'luxon'
|
||||||
/**
|
/**
|
||||||
* Update this with the max window that Teller supports
|
* Update this with the max window that Teller supports
|
||||||
*/
|
*/
|
||||||
export const TELLER_WINDOW_MAX = Duration.fromObject({ years: 1 })
|
export const TELLER_WINDOW_MAX = Duration.fromObject({ years: 2 })
|
||||||
|
|
||||||
export function getAccountBalanceData(
|
export function getAccountBalanceData(
|
||||||
{ balances, currency }: Pick<TellerTypes.AccountWithBalances, 'balances' | 'currency'>,
|
{ balance, currency }: Pick<TellerTypes.AccountWithBalances, 'balance' | 'currency'>,
|
||||||
classification: AccountClassification
|
classification: AccountClassification
|
||||||
): Pick<
|
): Pick<
|
||||||
Account,
|
Account,
|
||||||
|
@ -24,16 +24,11 @@ export function getAccountBalanceData(
|
||||||
| 'availableBalanceStrategy'
|
| 'availableBalanceStrategy'
|
||||||
| 'currencyCode'
|
| 'currencyCode'
|
||||||
> {
|
> {
|
||||||
// Flip balance values to positive for liabilities
|
|
||||||
const sign = classification === 'liability' ? -1 : 1
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentBalanceProvider: new Prisma.Decimal(
|
currentBalanceProvider: new Prisma.Decimal(balance.ledger ? Number(balance.ledger) : 0),
|
||||||
balances.ledger ? sign * Number(balances.ledger) : 0
|
|
||||||
),
|
|
||||||
currentBalanceStrategy: 'current',
|
currentBalanceStrategy: 'current',
|
||||||
availableBalanceProvider: new Prisma.Decimal(
|
availableBalanceProvider: new Prisma.Decimal(
|
||||||
balances.available ? sign * Number(balances.available) : 0
|
balance.available ? Number(balance.available) : 0
|
||||||
),
|
),
|
||||||
availableBalanceStrategy: 'available',
|
availableBalanceStrategy: 'available',
|
||||||
currencyCode: currency,
|
currencyCode: currency,
|
||||||
|
|
|
@ -34,7 +34,20 @@ export class TellerApi {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
async getAccounts({ accessToken }: AuthenticatedRequest): Promise<GetAccountsResponse> {
|
async getAccounts({ accessToken }: AuthenticatedRequest): Promise<GetAccountsResponse> {
|
||||||
return this.get<GetAccountsResponse>(`/accounts`, accessToken)
|
const accounts = await this.get<GetAccountsResponse>(`/accounts`, accessToken)
|
||||||
|
const accountsWithBalances = await Promise.all(
|
||||||
|
accounts.map(async (account) => {
|
||||||
|
const balance = await this.getAccountBalances({
|
||||||
|
accountId: account.id,
|
||||||
|
accessToken,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
balance,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return accountsWithBalances
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -137,12 +150,12 @@ export class TellerApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getApi(accessToken: string): Promise<AxiosInstance> {
|
private async getApi(accessToken: string): Promise<AxiosInstance> {
|
||||||
const cert = fs.readFileSync('../../../certs/teller-certificate.pem', 'utf8')
|
const cert = fs.readFileSync('./certs/certificate.pem')
|
||||||
const key = fs.readFileSync('../../../certs/teller-private-key.pem', 'utf8')
|
const key = fs.readFileSync('./certs/private_key.pem')
|
||||||
|
|
||||||
const agent = new https.Agent({
|
const agent = new https.Agent({
|
||||||
cert,
|
cert: cert,
|
||||||
key,
|
key: key,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!this.api) {
|
if (!this.api) {
|
||||||
|
@ -153,16 +166,16 @@ export class TellerApi {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
|
auth: {
|
||||||
|
username: accessToken,
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
} else if (this.api.defaults.auth?.username !== accessToken) {
|
||||||
this.api.interceptors.request.use((config) => {
|
this.api.defaults.auth = {
|
||||||
// Add the access_token to the auth object
|
username: accessToken,
|
||||||
config.auth = {
|
password: '',
|
||||||
username: 'ACCESS_TOKEN',
|
}
|
||||||
password: accessToken,
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.api
|
return this.api
|
||||||
|
|
|
@ -12,13 +12,15 @@ export enum AccountType {
|
||||||
export type DepositorySubtypes =
|
export type DepositorySubtypes =
|
||||||
| 'checking'
|
| 'checking'
|
||||||
| 'savings'
|
| 'savings'
|
||||||
| 'money market'
|
| 'money_market'
|
||||||
| 'certificate of deposit'
|
| 'certificate_of_deposit'
|
||||||
| 'treasury'
|
| 'treasury'
|
||||||
| 'sweep'
|
| 'sweep'
|
||||||
|
|
||||||
export type CreditSubtype = 'credit_card'
|
export type CreditSubtype = 'credit_card'
|
||||||
|
|
||||||
|
export type AccountStatus = 'open' | 'closed'
|
||||||
|
|
||||||
interface BaseAccount {
|
interface BaseAccount {
|
||||||
enrollment_id: string
|
enrollment_id: string
|
||||||
links: {
|
links: {
|
||||||
|
@ -34,7 +36,7 @@ interface BaseAccount {
|
||||||
currency: string
|
currency: string
|
||||||
id: string
|
id: string
|
||||||
last_four: string
|
last_four: string
|
||||||
status: 'open' | 'closed'
|
status: AccountStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DepositoryAccount extends BaseAccount {
|
interface DepositoryAccount extends BaseAccount {
|
||||||
|
@ -50,10 +52,10 @@ interface CreditAccount extends BaseAccount {
|
||||||
export type Account = DepositoryAccount | CreditAccount
|
export type Account = DepositoryAccount | CreditAccount
|
||||||
|
|
||||||
export type AccountWithBalances = Account & {
|
export type AccountWithBalances = Account & {
|
||||||
balances: AccountBalance
|
balance: AccountBalance
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetAccountsResponse = Account[]
|
export type GetAccountsResponse = AccountWithBalances[]
|
||||||
export type GetAccountResponse = Account
|
export type GetAccountResponse = Account
|
||||||
export type DeleteAccountResponse = void
|
export type DeleteAccountResponse = void
|
||||||
|
|
||||||
|
|
13
libs/teller-api/src/types/enrollment.ts
Normal file
13
libs/teller-api/src/types/enrollment.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
export type Enrollment = {
|
||||||
|
accessToken: string
|
||||||
|
user: {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
enrollment: {
|
||||||
|
id: string
|
||||||
|
institution: {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signatures?: string[]
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ export * from './account-balance'
|
||||||
export * from './account-details'
|
export * from './account-details'
|
||||||
export * from './authentication'
|
export * from './authentication'
|
||||||
export * from './error'
|
export * from './error'
|
||||||
|
export * from './enrollment'
|
||||||
export * from './identity'
|
export * from './identity'
|
||||||
export * from './institutions'
|
export * from './institutions'
|
||||||
export * from './transactions'
|
export * from './transactions'
|
||||||
|
|
|
@ -9,6 +9,4 @@ export type Institution = {
|
||||||
|
|
||||||
type Capability = 'detail' | 'balance' | 'transaction' | 'identity'
|
type Capability = 'detail' | 'balance' | 'transaction' | 'identity'
|
||||||
|
|
||||||
export type GetInstitutionsResponse = {
|
export type GetInstitutionsResponse = Institution[]
|
||||||
institutions: Institution[]
|
|
||||||
}
|
|
||||||
|
|
|
@ -38,7 +38,7 @@ export type Transaction = {
|
||||||
details: {
|
details: {
|
||||||
category?: DetailCategory
|
category?: DetailCategory
|
||||||
processing_status: DetailProcessingStatus
|
processing_status: DetailProcessingStatus
|
||||||
counterparty: {
|
counterparty?: {
|
||||||
name?: string
|
name?: string
|
||||||
type?: 'organization' | 'person'
|
type?: 'organization' | 'person'
|
||||||
}
|
}
|
||||||
|
|
10
package.json
10
package.json
|
@ -4,8 +4,8 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nx run-many --target serve --projects=client,server,workers --parallel --host 0.0.0.0 --nx-bail=true --maxParallel=100",
|
"dev": "nx run-many --target serve --projects=client,server,workers --parallel --host 0.0.0.0 --nx-bail=true --maxParallel=100",
|
||||||
"dev:services": "COMPOSE_PROFILES=services docker-compose up -d",
|
"dev:services": "docker compose --profile services up -d",
|
||||||
"dev:services:all": "COMPOSE_PROFILES=services,ngrok docker-compose up -d",
|
"dev:services:all": "docker compose --profile services --profile ngrok up -d",
|
||||||
"dev:workers:test": "nx test workers --skip-nx-cache --runInBand",
|
"dev:workers:test": "nx test workers --skip-nx-cache --runInBand",
|
||||||
"dev:server:test": "nx test server --skip-nx-cache --runInBand",
|
"dev:server:test": "nx test server --skip-nx-cache --runInBand",
|
||||||
"dev:test:unit": "yarn dev:ci:test --testPathPattern='^(?!.*integration).*$' --verbose --skip-nx-cache",
|
"dev:test:unit": "yarn dev:ci:test --testPathPattern='^(?!.*integration).*$' --verbose --skip-nx-cache",
|
||||||
|
@ -23,7 +23,7 @@
|
||||||
"prisma:migrate:reset": "prisma migrate reset",
|
"prisma:migrate:reset": "prisma migrate reset",
|
||||||
"prisma:seed": "prisma db seed",
|
"prisma:seed": "prisma db seed",
|
||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
"dev:docker:reset": "docker-compose down -v --rmi all --remove-orphans && docker system prune --all --volumes && docker-compose build",
|
"dev:docker:reset": "docker compose down -v --rmi all --remove-orphans && docker system prune --all --volumes && docker compose build",
|
||||||
"dev:circular": "npx madge --circular --extensions ts libs",
|
"dev:circular": "npx madge --circular --extensions ts libs",
|
||||||
"analyze:client": "ANALYZE=true nx build client --skip-nx-cache",
|
"analyze:client": "ANALYZE=true nx build client --skip-nx-cache",
|
||||||
"tools:pages": "live-server tools/pages",
|
"tools:pages": "live-server tools/pages",
|
||||||
|
@ -90,6 +90,7 @@
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bull": "^4.10.2",
|
"bull": "^4.10.2",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
|
"cookie": "^0.6.0",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"core-js": "^3.6.5",
|
"core-js": "^3.6.5",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
@ -148,12 +149,14 @@
|
||||||
"react-popper": "^2.3.0",
|
"react-popper": "^2.3.0",
|
||||||
"react-ranger": "^2.1.0",
|
"react-ranger": "^2.1.0",
|
||||||
"react-responsive": "^9.0.0-beta.10",
|
"react-responsive": "^9.0.0-beta.10",
|
||||||
|
"react-script-hook": "^1.7.2",
|
||||||
"regenerator-runtime": "0.13.7",
|
"regenerator-runtime": "0.13.7",
|
||||||
"sanitize-html": "^2.8.1",
|
"sanitize-html": "^2.8.1",
|
||||||
"smooth-scroll-into-view-if-needed": "^1.1.33",
|
"smooth-scroll-into-view-if-needed": "^1.1.33",
|
||||||
"stripe": "^10.17.0",
|
"stripe": "^10.17.0",
|
||||||
"superjson": "^1.11.0",
|
"superjson": "^1.11.0",
|
||||||
"tailwindcss": "3.2.4",
|
"tailwindcss": "3.2.4",
|
||||||
|
"teller-connect-react": "^0.1.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"winston": "^3.8.2",
|
"winston": "^3.8.2",
|
||||||
|
@ -165,6 +168,7 @@
|
||||||
"@babel/core": "7.17.5",
|
"@babel/core": "7.17.5",
|
||||||
"@babel/preset-react": "^7.14.5",
|
"@babel/preset-react": "^7.14.5",
|
||||||
"@babel/preset-typescript": "7.16.7",
|
"@babel/preset-typescript": "7.16.7",
|
||||||
|
"@faker-js/faker": "^8.3.1",
|
||||||
"@fast-csv/parse": "^4.3.6",
|
"@fast-csv/parse": "^4.3.6",
|
||||||
"@next/bundle-analyzer": "^13.1.1",
|
"@next/bundle-analyzer": "^13.1.1",
|
||||||
"@nrwl/cli": "15.5.2",
|
"@nrwl/cli": "15.5.2",
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "Provider" ADD VALUE 'TELLER';
|
|
@ -0,0 +1,9 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `teller_account_id` on the `account_connection` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "account_connection" DROP COLUMN "teller_account_id",
|
||||||
|
ADD COLUMN "teller_enrollment_id" TEXT;
|
|
@ -0,0 +1,223 @@
|
||||||
|
-- AlterTable
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
(finicity_categorization ->> 'category' :: text) = ANY (ARRAY ['Income'::text, 'Paycheck'::text])
|
||||||
|
) THEN 'Income' :: text
|
||||||
|
WHEN (
|
||||||
|
(finicity_categorization ->> 'category' :: text) = 'Mortgage & Rent' :: text
|
||||||
|
) THEN 'Housing Payments' :: text
|
||||||
|
WHEN (
|
||||||
|
(finicity_categorization ->> 'category' :: text) = ANY (
|
||||||
|
ARRAY ['Furnishings'::text, 'Home Services'::text, 'Home Improvement'::text, 'Lawn and Garden'::text]
|
||||||
|
)
|
||||||
|
) THEN 'Home Improvement' :: text
|
||||||
|
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
|
||||||
|
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
|
||||||
|
WHEN (
|
||||||
|
(finicity_categorization ->> 'category' :: text) = ANY (
|
||||||
|
ARRAY ['Auto & Transport'::text, 'Gas & Fuel'::text, 'Auto Insurance'::text]
|
||||||
|
)
|
||||||
|
) THEN 'Transportation' :: text
|
||||||
|
WHEN (
|
||||||
|
(finicity_categorization ->> 'category' :: text) = ANY (
|
||||||
|
ARRAY ['Hotel'::text, 'Travel'::text, 'Rental Car & Taxi'::text]
|
||||||
|
)
|
||||||
|
) THEN 'Travel' :: text
|
||||||
|
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
|
||||||
|
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";
|
||||||
|
|
||||||
|
COMMIT;
|
|
@ -71,8 +71,8 @@ model AccountConnection {
|
||||||
finicityError Json? @map("finicity_error")
|
finicityError Json? @map("finicity_error")
|
||||||
|
|
||||||
// teller data
|
// teller data
|
||||||
tellerAccountId String? @map("teller_account_id")
|
|
||||||
tellerAccessToken String? @map("teller_access_token")
|
tellerAccessToken String? @map("teller_access_token")
|
||||||
|
tellerEnrollmentId String? @map("teller_enrollment_id")
|
||||||
tellerInstitutionId String? @map("teller_institution_id")
|
tellerInstitutionId String? @map("teller_institution_id")
|
||||||
tellerError Json? @map("teller_error")
|
tellerError Json? @map("teller_error")
|
||||||
|
|
||||||
|
@ -341,7 +341,7 @@ model Transaction {
|
||||||
currencyCode String @default("USD") @map("currency_code")
|
currencyCode String @default("USD") @map("currency_code")
|
||||||
pending Boolean @default(false)
|
pending Boolean @default(false)
|
||||||
merchantName String? @map("merchant_name")
|
merchantName String? @map("merchant_name")
|
||||||
category String @default(dbgenerated("COALESCE(category_user,\nCASE\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'INCOME'::text) THEN 'Income'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = ANY (ARRAY['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text])) THEN 'Housing Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = 'LOAN_PAYMENTS_CAR_PAYMENT'::text) THEN 'Vehicle Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'LOAN_PAYMENTS'::text) THEN 'Other Payments'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'HOME_IMPROVEMENT'::text) THEN 'Home Improvement'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'GENERAL_MERCHANDISE'::text) THEN 'Shopping'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = 'RENT_AND_UTILITIES'::text) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'RENT_AND_UTILITIES_RENT'::text)) THEN 'Utilities'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'FOOD_AND_DRINK'::text) THEN 'Food and Drink'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRANSPORTATION'::text) THEN 'Transportation'::text\n WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRAVEL'::text) THEN 'Travel'::text\n WHEN (((plaid_personal_finance_category ->> 'primary'::text) = ANY (ARRAY['PERSONAL_CARE'::text, 'MEDICAL'::text])) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'MEDICAL_VETERINARY_SERVICES'::text)) THEN 'Health'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Income'::text, 'Paycheck'::text])) THEN 'Income'::text\n WHEN ((finicity_categorization ->> 'category'::text) = 'Mortgage & Rent'::text) THEN 'Housing Payments'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Furnishings'::text, 'Home Services'::text, 'Home Improvement'::text, 'Lawn and Garden'::text])) THEN 'Home Improvement'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Streaming Services'::text, 'Home Phone'::text, 'Television'::text, 'Bills & Utilities'::text, 'Utilities'::text, 'Internet / Broadband Charges'::text, 'Mobile Phone'::text])) THEN 'Utilities'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Fast Food'::text, 'Food & Dining'::text, 'Restaurants'::text, 'Coffee Shops'::text, 'Alcohol & Bars'::text, 'Groceries'::text])) THEN 'Food and Drink'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Auto & Transport'::text, 'Gas & Fuel'::text, 'Auto Insurance'::text])) THEN 'Transportation'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Hotel'::text, 'Travel'::text, 'Rental Car & Taxi'::text])) THEN 'Travel'::text\n WHEN ((finicity_categorization ->> 'category'::text) = ANY (ARRAY['Health Insurance'::text, 'Doctor'::text, 'Pharmacy'::text, 'Eyecare'::text, 'Health & Fitness'::text, 'Personal Care'::text])) THEN 'Health'::text\n 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 ((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)"))
|
||||||
categoryUser String? @map("category_user")
|
categoryUser String? @map("category_user")
|
||||||
excluded Boolean @default(false)
|
excluded Boolean @default(false)
|
||||||
|
|
||||||
|
@ -494,6 +494,7 @@ model Institution {
|
||||||
enum Provider {
|
enum Provider {
|
||||||
PLAID
|
PLAID
|
||||||
FINICITY
|
FINICITY
|
||||||
|
TELLER
|
||||||
}
|
}
|
||||||
|
|
||||||
model ProviderInstitution {
|
model ProviderInstitution {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { Institution, PrismaClient, Provider } from '@prisma/client'
|
import { Institution, PrismaClient, Provider } from '@prisma/client'
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
|
||||||
const prisma = new PrismaClient()
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
@ -27,7 +28,23 @@ async function main() {
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash('TestPassword123', 10)
|
||||||
|
|
||||||
await prisma.$transaction([
|
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
|
// create institution linked to provider institutions
|
||||||
...institutions.map(({ id, name, providers }) =>
|
...institutions.map(({ id, name, providers }) =>
|
||||||
prisma.institution.upsert({
|
prisma.institution.upsert({
|
||||||
|
|
1
tools/generators/index.ts
Normal file
1
tools/generators/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * as TellerGenerator from './tellerGenerator'
|
248
tools/generators/tellerGenerator.ts
Normal file
248
tools/generators/tellerGenerator.ts
Normal file
|
@ -0,0 +1,248 @@
|
||||||
|
import { faker } from '@faker-js/faker'
|
||||||
|
import type { TellerTypes } from '../../libs/teller-api/src'
|
||||||
|
|
||||||
|
function generateSubType(
|
||||||
|
type: TellerTypes.AccountTypes
|
||||||
|
): TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype {
|
||||||
|
if (type === 'depository') {
|
||||||
|
return faker.helpers.arrayElement([
|
||||||
|
'checking',
|
||||||
|
'savings',
|
||||||
|
'money_market',
|
||||||
|
'certificate_of_deposit',
|
||||||
|
'treasury',
|
||||||
|
'sweep',
|
||||||
|
]) as TellerTypes.DepositorySubtypes
|
||||||
|
} else {
|
||||||
|
return 'credit_card' as TellerTypes.CreditSubtype
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerateAccountsParams = {
|
||||||
|
count: number
|
||||||
|
enrollmentId: string
|
||||||
|
institutionName: string
|
||||||
|
institutionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateAccounts({
|
||||||
|
count,
|
||||||
|
enrollmentId,
|
||||||
|
institutionName,
|
||||||
|
institutionId,
|
||||||
|
}: GenerateAccountsParams) {
|
||||||
|
const accounts: TellerTypes.Account[] = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const accountId = faker.string.uuid()
|
||||||
|
const lastFour = faker.finance.creditCardNumber().slice(-4)
|
||||||
|
const type: TellerTypes.AccountTypes = faker.helpers.arrayElement(['depository', 'credit'])
|
||||||
|
let subType: TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype
|
||||||
|
subType = generateSubType(type)
|
||||||
|
|
||||||
|
const accountStub = {
|
||||||
|
enrollment_id: enrollmentId,
|
||||||
|
links: {
|
||||||
|
balances: `https://api.teller.io/accounts/${accountId}/balances`,
|
||||||
|
self: `https://api.teller.io/accounts/${accountId}`,
|
||||||
|
transactions: `https://api.teller.io/accounts/${accountId}/transactions`,
|
||||||
|
},
|
||||||
|
institution: {
|
||||||
|
name: institutionName,
|
||||||
|
id: institutionId,
|
||||||
|
},
|
||||||
|
name: faker.finance.accountName(),
|
||||||
|
currency: 'USD',
|
||||||
|
id: accountId,
|
||||||
|
last_four: lastFour,
|
||||||
|
status: faker.helpers.arrayElement(['open', 'closed']) as TellerTypes.AccountStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (faker.datatype.boolean()) {
|
||||||
|
accounts.push({
|
||||||
|
...accountStub,
|
||||||
|
type: 'depository',
|
||||||
|
subtype: faker.helpers.arrayElement([
|
||||||
|
'checking',
|
||||||
|
'savings',
|
||||||
|
'money_market',
|
||||||
|
'certificate_of_deposit',
|
||||||
|
'treasury',
|
||||||
|
'sweep',
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
accounts.push({
|
||||||
|
...accountStub,
|
||||||
|
type: 'credit',
|
||||||
|
subtype: 'credit_card',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateBalance(account_id: string): TellerTypes.AccountBalance {
|
||||||
|
const amount = faker.finance.amount()
|
||||||
|
return {
|
||||||
|
available: amount,
|
||||||
|
ledger: amount,
|
||||||
|
links: {
|
||||||
|
account: `https://api.teller.io/accounts/${account_id}`,
|
||||||
|
self: `https://api.teller.io/accounts/${account_id}/balances`,
|
||||||
|
},
|
||||||
|
account_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerateAccountsWithBalancesParams = {
|
||||||
|
count: number
|
||||||
|
enrollmentId: string
|
||||||
|
institutionName: string
|
||||||
|
institutionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateAccountsWithBalances({
|
||||||
|
count,
|
||||||
|
enrollmentId,
|
||||||
|
institutionName,
|
||||||
|
institutionId,
|
||||||
|
}: GenerateAccountsWithBalancesParams): TellerTypes.AccountWithBalances[] {
|
||||||
|
const accountsWithBalances: TellerTypes.AccountWithBalances[] = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const account = generateAccounts({
|
||||||
|
count,
|
||||||
|
enrollmentId,
|
||||||
|
institutionName,
|
||||||
|
institutionId,
|
||||||
|
})[0]
|
||||||
|
const balance = generateBalance(account.id)
|
||||||
|
accountsWithBalances.push({
|
||||||
|
...account,
|
||||||
|
balance,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return accountsWithBalances
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateTransactions(count: number, accountId: string): TellerTypes.Transaction[] {
|
||||||
|
const transactions: TellerTypes.Transaction[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const transactionId = `txn_${faker.string.uuid()}`
|
||||||
|
const transaction = {
|
||||||
|
details: {
|
||||||
|
processing_status: faker.helpers.arrayElement(['complete', 'pending']),
|
||||||
|
category: faker.helpers.arrayElement([
|
||||||
|
'accommodation',
|
||||||
|
'advertising',
|
||||||
|
'bar',
|
||||||
|
'charity',
|
||||||
|
'clothing',
|
||||||
|
'dining',
|
||||||
|
'education',
|
||||||
|
'electronics',
|
||||||
|
'entertainment',
|
||||||
|
'fuel',
|
||||||
|
'general',
|
||||||
|
'groceries',
|
||||||
|
'health',
|
||||||
|
'home',
|
||||||
|
'income',
|
||||||
|
'insurance',
|
||||||
|
'investment',
|
||||||
|
'loan',
|
||||||
|
'office',
|
||||||
|
'phone',
|
||||||
|
'service',
|
||||||
|
'shopping',
|
||||||
|
'software',
|
||||||
|
'sport',
|
||||||
|
'tax',
|
||||||
|
'transport',
|
||||||
|
'transportation',
|
||||||
|
'utilities',
|
||||||
|
]),
|
||||||
|
counterparty: {
|
||||||
|
name: faker.company.name(),
|
||||||
|
type: faker.helpers.arrayElement(['person', 'business']),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
running_balance: null,
|
||||||
|
description: faker.word.words({ count: { min: 3, max: 10 } }),
|
||||||
|
id: transactionId,
|
||||||
|
date: faker.date.recent({ days: 30 }).toISOString().split('T')[0], // recent date in 'YYYY-MM-DD' format
|
||||||
|
account_id: accountId,
|
||||||
|
links: {
|
||||||
|
account: `https://api.teller.io/accounts/${accountId}`,
|
||||||
|
self: `https://api.teller.io/accounts/${accountId}/transactions/${transactionId}`,
|
||||||
|
},
|
||||||
|
amount: faker.finance.amount(),
|
||||||
|
type: faker.helpers.arrayElement(['transfer', 'deposit', 'withdrawal']),
|
||||||
|
status: faker.helpers.arrayElement(['pending', 'posted']),
|
||||||
|
} as TellerTypes.Transaction
|
||||||
|
transactions.push(transaction)
|
||||||
|
}
|
||||||
|
return transactions
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateEnrollment(): TellerTypes.Enrollment & { institutionId: string } {
|
||||||
|
const institutionName = faker.company.name()
|
||||||
|
const institutionId = institutionName.toLowerCase().replace(/\s/g, '_')
|
||||||
|
return {
|
||||||
|
accessToken: `token_${faker.string.alphanumeric(15)}`,
|
||||||
|
user: {
|
||||||
|
id: `usr_${faker.string.alphanumeric(15)}`,
|
||||||
|
},
|
||||||
|
enrollment: {
|
||||||
|
id: `enr_${faker.string.alphanumeric(15)}`,
|
||||||
|
institution: {
|
||||||
|
name: institutionName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
signatures: [faker.string.alphanumeric(15)],
|
||||||
|
institutionId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerateConnectionsResponse = {
|
||||||
|
enrollment: TellerTypes.Enrollment & { institutionId: string }
|
||||||
|
accounts: TellerTypes.Account[]
|
||||||
|
accountsWithBalances: TellerTypes.AccountWithBalances[]
|
||||||
|
transactions: TellerTypes.Transaction[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateConnection(): GenerateConnectionsResponse {
|
||||||
|
const accountsWithBalances: TellerTypes.AccountWithBalances[] = []
|
||||||
|
const accounts: TellerTypes.Account[] = []
|
||||||
|
const transactions: TellerTypes.Transaction[] = []
|
||||||
|
|
||||||
|
const enrollment = generateEnrollment()
|
||||||
|
|
||||||
|
const accountCount: number = faker.number.int({ min: 1, max: 3 })
|
||||||
|
|
||||||
|
const enrollmentId = enrollment.enrollment.id
|
||||||
|
const institutionName = enrollment.enrollment.institution.name
|
||||||
|
const institutionId = enrollment.institutionId
|
||||||
|
accountsWithBalances.push(
|
||||||
|
...generateAccountsWithBalances({
|
||||||
|
count: accountCount,
|
||||||
|
enrollmentId,
|
||||||
|
institutionName,
|
||||||
|
institutionId,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
for (const account of accountsWithBalances) {
|
||||||
|
const { balance, ...accountWithoutBalance } = account
|
||||||
|
accounts.push(accountWithoutBalance)
|
||||||
|
const transactionsCount: number = faker.number.int({ min: 1, max: 5 })
|
||||||
|
const generatedTransactions = generateTransactions(transactionsCount, account.id)
|
||||||
|
transactions.push(...generatedTransactions)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enrollment,
|
||||||
|
accounts,
|
||||||
|
accountsWithBalances,
|
||||||
|
transactions,
|
||||||
|
}
|
||||||
|
}
|
|
@ -405,7 +405,7 @@
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"executor": "@nrwl/jest:jest",
|
"executor": "@nrwl/jest:jest",
|
||||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
"outputs": ["/coverage/libs/teller-api"],
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "libs/teller-api/jest.config.ts",
|
"jestConfig": "libs/teller-api/jest.config.ts",
|
||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
|
|
19
yarn.lock
19
yarn.lock
|
@ -1447,6 +1447,11 @@
|
||||||
minimatch "^3.1.2"
|
minimatch "^3.1.2"
|
||||||
strip-json-comments "^3.1.1"
|
strip-json-comments "^3.1.1"
|
||||||
|
|
||||||
|
"@faker-js/faker@^8.3.1":
|
||||||
|
version "8.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-8.3.1.tgz#7753df0cb88d7649becf984a96dd1bd0a26f43e3"
|
||||||
|
integrity sha512-FdgpFxY6V6rLZE9mmIBb9hM0xpfvQOSNOLnzolzKwsE1DH+gC7lEKV1p1IbR0lAYyvYd5a4u3qWJzowUkw1bIw==
|
||||||
|
|
||||||
"@fast-csv/format@^4.3.5":
|
"@fast-csv/format@^4.3.5":
|
||||||
version "4.3.5"
|
version "4.3.5"
|
||||||
resolved "https://registry.yarnpkg.com/@fast-csv/format/-/format-4.3.5.tgz#90d83d1b47b6aaf67be70d6118f84f3e12ee1ff3"
|
resolved "https://registry.yarnpkg.com/@fast-csv/format/-/format-4.3.5.tgz#90d83d1b47b6aaf67be70d6118f84f3e12ee1ff3"
|
||||||
|
@ -7770,7 +7775,7 @@ cookie@0.5.0, cookie@^0.5.0:
|
||||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
|
||||||
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
|
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
|
||||||
|
|
||||||
cookie@0.6.0:
|
cookie@0.6.0, cookie@^0.6.0:
|
||||||
version "0.6.0"
|
version "0.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
|
||||||
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
|
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
|
||||||
|
@ -16243,6 +16248,11 @@ react-script-hook@^1.6.0:
|
||||||
resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.7.2.tgz#ec130d67f9a25fcde57fbfd1faa87e5b97521948"
|
resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.7.2.tgz#ec130d67f9a25fcde57fbfd1faa87e5b97521948"
|
||||||
integrity sha512-fhyCEfXb94fag34UPRF0zry1XGwmVY+79iibWwTqAoOiCzYJQOYTiWJ7CnqglA9tMSV8g45cQpHCMcBwr7dwhA==
|
integrity sha512-fhyCEfXb94fag34UPRF0zry1XGwmVY+79iibWwTqAoOiCzYJQOYTiWJ7CnqglA9tMSV8g45cQpHCMcBwr7dwhA==
|
||||||
|
|
||||||
|
react-script-hook@^1.7.2:
|
||||||
|
version "1.7.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-script-hook/-/react-script-hook-1.7.2.tgz#ec130d67f9a25fcde57fbfd1faa87e5b97521948"
|
||||||
|
integrity sha512-fhyCEfXb94fag34UPRF0zry1XGwmVY+79iibWwTqAoOiCzYJQOYTiWJ7CnqglA9tMSV8g45cQpHCMcBwr7dwhA==
|
||||||
|
|
||||||
react-shallow-renderer@^16.15.0:
|
react-shallow-renderer@^16.15.0:
|
||||||
version "16.15.0"
|
version "16.15.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz#48fb2cf9b23d23cde96708fe5273a7d3446f4457"
|
resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz#48fb2cf9b23d23cde96708fe5273a7d3446f4457"
|
||||||
|
@ -18150,6 +18160,13 @@ telejson@^6.0.8:
|
||||||
lodash "^4.17.21"
|
lodash "^4.17.21"
|
||||||
memoizerific "^1.11.3"
|
memoizerific "^1.11.3"
|
||||||
|
|
||||||
|
teller-connect-react@^0.1.0:
|
||||||
|
version "0.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/teller-connect-react/-/teller-connect-react-0.1.0.tgz#b3bae24f4410d622eb8c88c7668adb003eb7bfd7"
|
||||||
|
integrity sha512-ZI+OULCsuo/v1qetpjepOgM7TyIzwnMVE/54IruOPguQtJ/Ui3C1ax3wUb65AKZDyVQ7ZyjA+8ypT/yMYD9bIQ==
|
||||||
|
dependencies:
|
||||||
|
react-script-hook "^1.7.2"
|
||||||
|
|
||||||
terminal-link@^2.0.0:
|
terminal-link@^2.0.0:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994"
|
resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue