├── .editorconfig ├── .env.template ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── client ├── .dockerignore ├── .eslintrc ├── .gitignore ├── .prettierrc.json ├── Dockerfile ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── App.scss │ ├── App.tsx │ ├── components │ │ ├── AccountCard.tsx │ │ ├── AddUserForm.tsx │ │ ├── Asset.tsx │ │ ├── Banner.tsx │ │ ├── CategoriesChart.tsx │ │ ├── DuplicateItemToast.tsx │ │ ├── ErrorMessage.tsx │ │ ├── ItemCard.tsx │ │ ├── Landing.tsx │ │ ├── LaunchLink.tsx │ │ ├── LoadingCallout.tsx │ │ ├── Login.tsx │ │ ├── MoreDetails.tsx │ │ ├── NetWorth.tsx │ │ ├── OAuthLink.tsx │ │ ├── Sockets.jsx │ │ ├── SpendingInsights.tsx │ │ ├── TransactionsTable.tsx │ │ ├── UserCard.tsx │ │ ├── UserDetails.tsx │ │ ├── UserList.tsx │ │ ├── UserPage.tsx │ │ ├── index.ts │ │ └── types.ts │ ├── hooks │ │ ├── index.ts │ │ ├── useBoolean.ts │ │ └── useOnClickOutside.ts │ ├── index.js │ ├── index.scss │ ├── react-app-env.d.ts │ ├── services │ │ ├── accounts.tsx │ │ ├── api.tsx │ │ ├── assets.tsx │ │ ├── currentUser.tsx │ │ ├── errors.tsx │ │ ├── index.js │ │ ├── institutions.tsx │ │ ├── items.tsx │ │ ├── link.tsx │ │ ├── transactions.tsx │ │ └── users.tsx │ └── util │ │ └── index.tsx └── tsconfig.json ├── database └── init │ └── create.sql ├── docker-compose.debug.yml ├── docker-compose.yml ├── docs ├── .DS_Store ├── pattern_screenshot.jpg └── troubleshooting.md ├── ngrok ├── Dockerfile ├── entrypoint.sh └── ngrok.yml ├── server ├── .dockerignore ├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── Dockerfile ├── db │ ├── index.js │ └── queries │ │ ├── accounts.js │ │ ├── assets.js │ │ ├── index.js │ │ ├── items.js │ │ ├── linkEvents.js │ │ ├── plaidApiEvents.js │ │ ├── transactions.js │ │ └── users.js ├── index.js ├── middleware.js ├── package-lock.json ├── package.json ├── plaid.js ├── routes │ ├── accounts.js │ ├── assets.js │ ├── index.js │ ├── institutions.js │ ├── items.js │ ├── linkEvents.js │ ├── linkTokens.js │ ├── services.js │ ├── sessions.js │ ├── unhandled.js │ └── users.js ├── update_transactions.js ├── util.js └── webhookHandlers │ ├── handleItemWebhook.js │ ├── handleTransactionsWebhook.js │ ├── index.js │ └── unhandledWebhook.js ├── version.sh └── wait-for-client.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = false 9 | 10 | [Makefile] 11 | indent_style = tab 12 | 13 | [*.md] 14 | indent_size = 4 15 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # Your Plaid keys, which can be found in the Plaid Dashboard. 2 | # https://dashboard.plaid.com/developers/keys 3 | # NOTE: To use Production, you must set a use case for Link. 4 | # You can do this in the Dashboard under Link -> Link Customization -> Data Transparency: 5 | # https://dashboard.plaid.com/link/data-transparency-v5 6 | 7 | PLAID_CLIENT_ID= 8 | PLAID_SECRET_PRODUCTION= 9 | PLAID_SECRET_SANDBOX= 10 | 11 | # The Plaid environment to use ('sandbox' or 'production'). 12 | # https://plaid.com/docs/#api-host 13 | 14 | PLAID_ENV=sandbox 15 | 16 | # IMPORTANT: Also update ngrok.yml in the /ngrok directory to add your authtoken 17 | 18 | 19 | # (Optional) Redirect URI settings section 20 | # Only required for OAuth redirect URI testing (not common on desktop): 21 | # Sandbox Mode: 22 | # Set the PLAID_SANDBOX_REDIRECT_URI below to 'http://localhost:3001/oauth-link'. 23 | # The OAuth redirect flow requires an endpoint on the developer's website 24 | # that the bank website should redirect to. You will also need to configure 25 | # this redirect URI for your client ID through the Plaid developer dashboard 26 | # at https://dashboard.plaid.com/team/api. 27 | # Production environment: 28 | # When running in the Production environment, you must use an https:// url. 29 | # You will need to configure this https:// redirect URI in the Plaid developer dashboard. 30 | # Instructions to create a self-signed certificate for localhost can be found at 31 | # https://github.com/plaid/pattern/blob/master/README.md#testing-oauth. 32 | # If your system is not set up to run localhost with https://, you will be unable to test 33 | # the OAuth in development and should leave the PLAID_PRODUCTION_REDIRECT_URI blank. 34 | 35 | PLAID_SANDBOX_REDIRECT_URI= 36 | PLAID_PRODUCTION_REDIRECT_URI= 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | database/last-cleared.dummy 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | *.pid.lock 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (https://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # TypeScript v1 declaration files 41 | typings/ 42 | dist/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # next.js build output 63 | .next 64 | 65 | # vscode 66 | .vscode/ 67 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Docker: Attach to Server", 9 | "type": "node", 10 | "request": "attach", 11 | "localRoot": "${workspaceFolder}/server", 12 | "remoteRoot": "/opt/server", 13 | "address": "localhost", 14 | "port": 9229, 15 | "protocol": "inspector", 16 | "restart": true, 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "compile-hero.disable-compile-files-on-did-save-code": false 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2020 Plaid Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST))) 2 | current_dir := $(notdir $(patsubst %/,%,$(dir $(mkfile_path)))) 3 | envfile := ./.env 4 | clear_db_after_schema_change := database/last-cleared.dummy 5 | db_schema := database/init/* 6 | 7 | .PHONY: help start start-no-webhooks debug sql logs stop clear-db 8 | 9 | # help target adapted from https://gist.github.com/prwhite/8168133#gistcomment-2278355 10 | TARGET_MAX_CHAR_NUM=20 11 | 12 | ## Show help 13 | help: 14 | @echo '' 15 | @echo 'Usage:' 16 | @echo ' make ' 17 | @echo '' 18 | @echo 'Targets:' 19 | @awk '/^[a-zA-Z_0-9-]+:/ { \ 20 | helpMessage = match(lastLine, /^## (.*)/); \ 21 | if (helpMessage) { \ 22 | helpCommand = substr($$1, 0, index($$1, ":")-1); \ 23 | helpMessage = substr(lastLine, RSTART + 3, RLENGTH); \ 24 | printf " %-$(TARGET_MAX_CHAR_NUM)s %s\n", helpCommand, helpMessage; \ 25 | } \ 26 | } \ 27 | { lastLine = $$0 }' $(MAKEFILE_LIST) 28 | 29 | ## Start the services 30 | start: $(envfile) $(clear_db_after_schema_change) 31 | @echo "Pulling images from Docker Hub (this may take a few minutes)" 32 | docker compose pull 33 | @echo "Starting Docker services" 34 | docker compose up --build --detach 35 | ./wait-for-client.sh 36 | 37 | ## Start the services without webhooks 38 | start-no-webhooks: $(envfile) $(clear_db_after_schema_change) 39 | @echo "Pulling images from Docker Hub (this may take a few minutes)" 40 | docker compose pull 41 | @echo "Starting Docker services" 42 | docker compose up --detach client 43 | ./wait-for-client.sh 44 | 45 | ## Start the services in debug mode 46 | debug: $(envfile) $(clear_db_after_schema_change) 47 | @echo "Starting services (this may take a few minutes if there are any changes)" 48 | docker compose -f docker-compose.yml -f docker-compose.debug.yml up --build --detach 49 | ./wait-for-client.sh 50 | 51 | ## Start an interactive psql session (services must running) 52 | sql: 53 | docker compose exec db psql -U postgres 54 | 55 | ## Show the service logs (services must be running) 56 | logs: 57 | docker compose logs --follow 58 | 59 | ## Stop the services 60 | stop: 61 | docker compose down 62 | docker volume rm $(current_dir)_{client,server}_node_modules 2>/dev/null || true 63 | 64 | ## Clear the sandbox and production databases 65 | clear-db: stop 66 | docker volume rm $(current_dir)_pg_{sandbox,production}_data 2>/dev/null || true 67 | 68 | $(envfile): 69 | @echo "Error: .env file does not exist! See the README for instructions." 70 | @exit 1 71 | 72 | # Remove local DBs if the DB schema has changed 73 | $(clear_db_after_schema_change): $(db_schema) 74 | @$(MAKE) clear-db 75 | @touch $(clear_db_after_schema_change) 76 | -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 1 | # node 2 | node_modules 3 | npm-debug.log 4 | 5 | # docker 6 | Dockerfile* 7 | docker-compose* 8 | .dockerignore 9 | 10 | # other 11 | .DS_Store 12 | .env 13 | .eslintrc* 14 | .gitignore 15 | .prettierrc* 16 | README.md 17 | -------------------------------------------------------------------------------- /client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app", 3 | "plugins": ["prettier"], 4 | "rules": { 5 | "prettier/prettier": "error" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # typescript 26 | 27 | src/components/dist/ 28 | src/services/dist/ 29 | src/hooks/dist/ 30 | src/util/dist/ 31 | -------------------------------------------------------------------------------- /client/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "bracketSpacing": true, 7 | "jsxBracketSameLine": false, 8 | "arrowParens": "avoid", 9 | "endOfLine": "auto" 10 | } 11 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | COPY ["package.json", "/opt/client/"] 4 | COPY ["package-lock.json", "/opt/client/"] 5 | 6 | WORKDIR /opt/client 7 | 8 | RUN npm ci 9 | 10 | COPY ["src/", "/opt/client/src/"] 11 | COPY ["public/", "/opt/client/public/"] 12 | 13 | CMD ["npm", "start"] 14 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "1.0.7", 4 | "private": true, 5 | "dependencies": { 6 | "@types/react": "^17.0.11", 7 | "@types/react-dom": "^17.0.7", 8 | "axios": "^0.21.4", 9 | "date-fns": "^1.30.1", 10 | "glob-parent": "6.0.2", 11 | "immer": "9.0.21", 12 | "levn": "0.3.0", 13 | "plaid": "^30.0.0", 14 | "plaid-threads": "14.5.0", 15 | "prop-types": "^15.7.2", 16 | "react": "^16.14.0", 17 | "react-dom": "^16.14.0", 18 | "react-plaid-link": "^3.2.1", 19 | "react-router-dom": "^5.1.2", 20 | "react-router-hash-link": "^2.4.3", 21 | "react-scripts": "5.0.1", 22 | "react-toastify": "^5.1.0", 23 | "recharts": "^2.0.9", 24 | "sass": "^1.42.1", 25 | "socket.io-client": "^4.5.4", 26 | "typescript": "^4.9.5" 27 | }, 28 | "scripts": { 29 | "start": "PORT=3001 react-scripts start", 30 | "build": "react-scripts build", 31 | "test": "react-scripts test", 32 | "eject": "react-scripts eject", 33 | "lint": "eslint src -c .eslintrc --ext js,jsx", 34 | "preinstall": "npx npm-force-resolutions" 35 | }, 36 | "eslintConfig": { 37 | "extends": "react-app" 38 | }, 39 | "browserslist": { 40 | "production": [ 41 | ">0.2%", 42 | "not dead", 43 | "not ie <= 11", 44 | "not op_mini all" 45 | ], 46 | "development": [ 47 | "last 1 chrome version", 48 | "last 1 firefox version", 49 | "last 1 safari version" 50 | ] 51 | }, 52 | "devDependencies": { 53 | "@types/lodash": "^4.14.170", 54 | "@types/node": "^15.14.0", 55 | "@types/react-router-dom": "^5.1.7", 56 | "@types/react-router-hash-link": "^2.4.0", 57 | "@types/socket.io-client": "^3.0.0", 58 | "eslint-plugin-prettier": "^3.0.1", 59 | "prettier": "^1.16.4", 60 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11" 61 | }, 62 | "overrides": { 63 | "chokidar": "3.5.3", 64 | "glob-parent": "6.0.2", 65 | "immer": "9.0.21", 66 | "levn": "0.3.0" 67 | }, 68 | "proxy": "http://server:5001" 69 | } 70 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plaid/pattern/e7a4cd4eba9e65edc482d5c5355f07e0fb3ca694/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 25 | Plaid Pattern 26 | 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Pattern", 3 | "name": "Plaid Pattern", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Switch, withRouter } from 'react-router-dom'; 3 | import { toast } from 'react-toastify'; 4 | import 'react-toastify/dist/ReactToastify.min.css'; 5 | 6 | import Sockets from "./components/Sockets.jsx"; 7 | import OAuthLink from './components/OAuthLink.tsx'; 8 | import Landing from './components/Landing.tsx'; 9 | import UserPage from "./components/UserPage.tsx"; 10 | import UserList from './components/UserList.tsx'; 11 | import { AccountsProvider } from './services/accounts.tsx'; 12 | import { InstitutionsProvider } from './services/institutions.tsx'; 13 | import { ItemsProvider } from './services/items.tsx'; 14 | import { LinkProvider } from './services/link.tsx'; 15 | import { TransactionsProvider } from './services/transactions.tsx'; 16 | import { UsersProvider } from './services/users.tsx'; 17 | import { CurrentUserProvider } from './services/currentUser.tsx'; 18 | import { AssetsProvider } from './services/assets.tsx'; 19 | import { ErrorsProvider } from './services/errors.tsx'; 20 | 21 | import './App.scss'; 22 | 23 | function App() { 24 | toast.configure({ 25 | autoClose: 8000, 26 | draggable: false, 27 | toastClassName: 'box toast__background', 28 | bodyClassName: 'toast__body', 29 | hideProgressBar: true, 30 | }); 31 | 32 | return ( 33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 |
60 | ); 61 | } 62 | 63 | export default withRouter(App); 64 | -------------------------------------------------------------------------------- /client/src/components/AccountCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import startCase from 'lodash/startCase'; 3 | import toLower from 'lodash/toLower'; 4 | import Button from 'plaid-threads/Button'; 5 | 6 | import { AccountType } from './types'; 7 | import useTransactions from '../services/transactions.tsx'; 8 | import { currencyFilter } from '../util/index.tsx'; 9 | import TransactionsTable from './TransactionsTable.tsx'; 10 | 11 | interface Props { 12 | account: AccountType; 13 | } 14 | 15 | // TODO: update all components to look like this: 16 | // const ClientMetrics: React.FC = (props: Props) => () 17 | 18 | // ClientMetrics.displayName = 'ClientMetrics'; 19 | // export default ClientMetrics; 20 | export default function AccountCard(props: Props) { 21 | const [transactions, setTransactions] = useState([]); 22 | const [transactionsShown, setTransactionsShown] = useState(false); 23 | 24 | const { transactionsByAccount, getTransactionsByAccount } = useTransactions(); 25 | 26 | const { id } = props.account; 27 | 28 | const toggleShowTransactions = () => { 29 | setTransactionsShown(shown => !shown); 30 | }; 31 | 32 | useEffect(() => { 33 | getTransactionsByAccount(id); 34 | }, [getTransactionsByAccount, transactionsByAccount, id]); 35 | 36 | useEffect(() => { 37 | setTransactions(transactionsByAccount[id] || []); 38 | }, [transactionsByAccount, id]); 39 | 40 | return ( 41 |
42 |
43 |
44 |
{props.account.name}
45 |
{`${startCase( 46 | toLower(props.account.subtype) 47 | )} • Balance ${currencyFilter(props.account.current_balance)}`}
48 |
49 |
50 | {transactions.length !== 0 && ( 51 | 54 | )} 55 |
56 |
57 | {transactionsShown && } 58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /client/src/components/AddUserForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import Button from 'plaid-threads/Button'; 3 | import TextInput from 'plaid-threads/TextInput'; 4 | 5 | import { useUsers, useCurrentUser } from '../services'; 6 | 7 | interface Props { 8 | hideForm: () => void; 9 | } 10 | const AddUserForm = (props: Props) => { 11 | const [username, setUsername] = useState(''); 12 | 13 | const { addNewUser, getUsers } = useUsers(); 14 | const { setNewUser } = useCurrentUser(); 15 | const handleSubmit = async (e: any) => { 16 | e.preventDefault(); 17 | await addNewUser(username); 18 | setNewUser(username); 19 | props.hideForm(); 20 | }; 21 | 22 | useEffect(() => { 23 | getUsers(true); 24 | }, [addNewUser, getUsers]); 25 | 26 | return ( 27 |
28 |
29 |
30 |
31 |

Add a new user

32 |

33 | Enter your name in the input field. 34 |

35 |
36 |
37 | setUsername(e.target.value)} 47 | /> 48 |
49 |
50 | 53 | 62 |
63 |
64 |
65 |
66 | ); 67 | }; 68 | 69 | export default AddUserForm; 70 | -------------------------------------------------------------------------------- /client/src/components/Asset.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Modal from 'plaid-threads/Modal'; 3 | import ModalBody from 'plaid-threads/ModalBody'; 4 | import Button from 'plaid-threads/Button'; 5 | import TextInput from 'plaid-threads/TextInput'; 6 | import NumberInput from 'plaid-threads/NumberInput'; 7 | 8 | import { useAssets } from '../services'; 9 | 10 | interface Props { 11 | userId: number; 12 | } 13 | 14 | // Allows user to input their personal assets such as a house or car. 15 | 16 | export default function Asset(props: Props) { 17 | const [show, setShow] = useState(false); 18 | const [description, setDescription] = useState(''); 19 | const [value, setValue] = useState(''); 20 | const { addAsset } = useAssets(); 21 | 22 | const handleSubmit = (e: any) => { 23 | e.preventDefault(); 24 | setShow(false); 25 | addAsset(props.userId, description, parseFloat(value)); 26 | setDescription(''); 27 | setValue(''); 28 | }; 29 | 30 | return ( 31 |
32 | 35 | setShow(false)}> 36 | <> 37 | setShow(false)} 41 | > 42 |
43 | setDescription(e.currentTarget.value)} 50 | /> 51 | setValue(e.currentTarget.value)} 58 | /> 59 | 62 | 63 |
64 | 65 |
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /client/src/components/Banner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from 'plaid-threads/Button'; 3 | 4 | const PLAID_ENV = process.env.REACT_APP_PLAID_ENV; 5 | 6 | interface Props { 7 | initialSubheading?: boolean; 8 | } 9 | 10 | const Banner = (props: Props) => { 11 | const initialText = 12 | 'This is an example app that outlines an end-to-end integration with Plaid.'; 13 | 14 | const successText = 15 | "This page shows a user's net worth, spending by category, and allows them to explore account and transactions details for linked items."; 16 | 17 | const subheadingText = props.initialSubheading ? initialText : successText; 18 | 19 | return ( 20 | 39 | ); 40 | }; 41 | 42 | export default Banner; 43 | -------------------------------------------------------------------------------- /client/src/components/CategoriesChart.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PieChart, Pie, Cell, Legend } from 'recharts'; 3 | import colors from 'plaid-threads/scss/colors.ts'; 4 | 5 | interface Props { 6 | categories: { 7 | [key: string]: number; 8 | }; 9 | } 10 | 11 | export default function CategoriesChart(props: Props) { 12 | const data = []; 13 | const labels = Object.keys(props.categories); 14 | const values = Object.values(props.categories); 15 | for (let i = 0; i < labels.length; i++) { 16 | data.push({ name: labels[i], value: Math.round(values[i]) }); 17 | } 18 | 19 | const COLORS = [ 20 | colors.yellow900, 21 | colors.red900, 22 | colors.blue900, 23 | colors.green900, 24 | colors.black1000, 25 | colors.purple600, 26 | ]; 27 | 28 | const renderLabel = (value: any) => { 29 | return `$${value.value.toLocaleString()}`; 30 | }; 31 | 32 | const sanitizedData = data.filter(entry => entry.value >= 0); 33 | 34 | return ( 35 |
36 |

Spending Categories

37 | 38 | 39 | 50 | {data.map((entry, index) => ( 51 | 52 | ))} 53 | 54 | 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /client/src/components/DuplicateItemToast.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface Props { 4 | institutionName: string | undefined; 5 | } 6 | 7 | const DuplicateItemToastMessage = (props: Props) => ( 8 | <> 9 |
{`${props.institutionName} already linked.`}
10 | 16 | Click here 17 | {' '} 18 | for more info. 19 | 20 | ); 21 | 22 | export default DuplicateItemToastMessage; 23 | -------------------------------------------------------------------------------- /client/src/components/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Callout } from 'plaid-threads/Callout'; 3 | import { IconButton } from 'plaid-threads/IconButton'; 4 | import { CloseS2 } from 'plaid-threads/Icons/CloseS2'; 5 | 6 | import useErrors from '../services/errors.tsx'; 7 | 8 | // Allows user to input their personal assets such as a house or car. 9 | 10 | export default function ErrorMessage() { 11 | const [show, setShow] = useState(false); 12 | const [message, setMessage] = useState(''); 13 | const { error, resetError } = useErrors(); 14 | 15 | useEffect(() => { 16 | const errors = [ 17 | 'INSTITUTION_NOT_RESPONDING', 18 | 'INSTITUTION_DOWN', 19 | 'INSTITUTION_NOT_AVAILABLE', 20 | 'INTERNAL_SERVER_ERROR', 21 | 'USER_SETUP_REQUIRED', 22 | 'ITEM_LOCKED', 23 | 'INVALID_CREDENTIALS', 24 | 'INVALID_UPDATED_USERNAME', 25 | 'INSUFFICIENT_CREDENTIALS', 26 | 'MFA_NOT_SUPPORTED', 27 | 'NO_ACCOUNTS', 28 | ]; 29 | 30 | if (error.code != null && errors.includes(error.code)) { 31 | setShow(true); 32 | setMessage(error.message); 33 | } 34 | }, [error.code, error.message]); 35 | 36 | return ( 37 | <> 38 | {show && ( 39 | 40 | { 44 | setShow(false); 45 | resetError(); 46 | }} 47 | icon={} 48 | /> 49 | Error: {error.code}
50 | {message} 51 |
52 | )} 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /client/src/components/ItemCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import Note from 'plaid-threads/Note'; 3 | import Touchable from 'plaid-threads/Touchable'; 4 | import { InlineLink } from 'plaid-threads/InlineLink'; 5 | import { Callout } from 'plaid-threads/Callout'; 6 | import { Institution } from 'plaid/dist/api'; 7 | 8 | import { ItemType, AccountType } from './types'; 9 | import AccountCard from './AccountCard.tsx'; 10 | import MoreDetails from './MoreDetails.tsx'; 11 | 12 | import { 13 | useAccounts, 14 | useInstitutions, 15 | useItems, 16 | useTransactions, 17 | } from '../services'; 18 | import { setItemToBadState } from '../services/api.tsx'; 19 | import { diffBetweenCurrentTime } from '../util/index.tsx'; 20 | 21 | const PLAID_ENV = process.env.REACT_APP_PLAID_ENV || 'sandbox'; 22 | 23 | interface Props { 24 | item: ItemType; 25 | userId: number; 26 | } 27 | 28 | const ItemCard = (props: Props) => { 29 | const [accounts, setAccounts] = useState([]); 30 | const [institution, setInstitution] = useState({ 31 | logo: '', 32 | name: '', 33 | institution_id: '', 34 | oauth: false, 35 | products: [], 36 | country_codes: [], 37 | routing_numbers: [], 38 | }); 39 | const [showAccounts, setShowAccounts] = useState(false); 40 | 41 | const { accountsByItem } = useAccounts(); 42 | const { deleteAccountsByItemId } = useAccounts(); 43 | const { deleteItemById } = useItems(); 44 | const { deleteTransactionsByItemId } = useTransactions(); 45 | const { 46 | institutionsById, 47 | getInstitutionById, 48 | formatLogoSrc, 49 | } = useInstitutions(); 50 | const { id, plaid_institution_id, status } = props.item; 51 | const isSandbox = PLAID_ENV === 'sandbox'; 52 | const isGoodState = status === 'good'; 53 | 54 | useEffect(() => { 55 | const itemAccounts: AccountType[] = accountsByItem[id]; 56 | setAccounts(itemAccounts || []); 57 | }, [accountsByItem, id]); 58 | 59 | useEffect(() => { 60 | setInstitution(institutionsById[plaid_institution_id] || {}); 61 | }, [institutionsById, plaid_institution_id]); 62 | 63 | useEffect(() => { 64 | getInstitutionById(plaid_institution_id); 65 | }, [getInstitutionById, plaid_institution_id]); 66 | 67 | const handleSetBadState = () => { 68 | setItemToBadState(id); 69 | }; 70 | const handleDeleteItem = () => { 71 | deleteItemById(id, props.userId); 72 | deleteAccountsByItemId(id); 73 | deleteTransactionsByItemId(id); 74 | }; 75 | 76 | const cardClassNames = showAccounts 77 | ? 'card item-card expanded' 78 | : 'card item-card'; 79 | return ( 80 |
81 |
82 | setShowAccounts(current => !current)} 85 | > 86 |
87 | {institution 92 |

{institution && institution.name}

93 |
94 |
95 | {isGoodState ? ( 96 | 97 | Updated 98 | 99 | ) : ( 100 | 101 | Login Required 102 | 103 | )} 104 |
105 |
106 |

LAST UPDATED

107 |

108 | {diffBetweenCurrentTime(props.item.updated_at)} 109 |

110 |
111 |
112 | 119 |
120 | {showAccounts && accounts.length > 0 && ( 121 |
122 | {accounts.map(account => ( 123 |
124 | 125 |
126 | ))} 127 |
128 | )} 129 | {showAccounts && accounts.length === 0 && ( 130 | 131 | No transactions or accounts have been retrieved for this item. See the{' '} 132 | 133 | {' '} 134 | troubleshooting guide{' '} 135 | {' '} 136 | to learn about receiving transactions webhooks with this sample app. 137 | 138 | )} 139 |
140 | ); 141 | }; 142 | 143 | export default ItemCard; 144 | -------------------------------------------------------------------------------- /client/src/components/Landing.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import Button from 'plaid-threads/Button'; 3 | import { useHistory } from 'react-router-dom'; 4 | 5 | import useCurrentUser from '../services/currentUser.tsx'; 6 | import Login from './Login.tsx'; 7 | import Banner from './Banner.tsx'; 8 | import AddUserForm from './AddUserForm.tsx'; 9 | 10 | import useBoolean from '../hooks/useBoolean.ts'; 11 | 12 | export default function Landing() { 13 | const { userState, setCurrentUser } = useCurrentUser(); 14 | const [isAdding, hideForm, toggleForm] = useBoolean(false); 15 | const history = useHistory(); 16 | 17 | useEffect(() => { 18 | if (userState.newUser != null) { 19 | setCurrentUser(userState.newUser); 20 | } 21 | }, [setCurrentUser, userState.newUser]); 22 | 23 | const returnToCurrentUser = () => { 24 | history.push(`/user/${userState.currentUser.id}`); 25 | }; 26 | return ( 27 |
28 | 29 | If you don't have an account, please click "Create Account". Once created, 30 | you can add as many example Link items as you like. 31 |
32 | 33 | 36 | {userState.currentUser.username != null && ( 37 | 45 | )} 46 |
47 | {isAdding && } 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /client/src/components/LaunchLink.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { 3 | usePlaidLink, 4 | PlaidLinkOnSuccessMetadata, 5 | PlaidLinkOnExitMetadata, 6 | PlaidLinkError, 7 | PlaidLinkOptionsWithLinkToken, 8 | PlaidLinkOnEventMetadata, 9 | PlaidLinkStableEvent, 10 | } from 'react-plaid-link'; 11 | import { useHistory } from 'react-router-dom'; 12 | 13 | import { logEvent, logSuccess, logExit } from '../util/index.tsx'; // functions to log and save errors and metadata from Link events 14 | 15 | import useItems from '../services/items.tsx'; 16 | import useLink from '../services/link.tsx'; 17 | import useErrors from '../services/errors.tsx'; 18 | 19 | import { exchangeToken, setItemState } from '../services/api.tsx' 20 | 21 | interface Props { 22 | isOauth?: boolean; 23 | token: string; 24 | userId: number; 25 | itemId?: number | null; 26 | children?: React.ReactNode; 27 | } 28 | 29 | // Uses the usePlaidLink hook to manage the Plaid Link creation. See https://github.com/plaid/react-plaid-link for full usage instructions. 30 | // The link token passed to usePlaidLink cannot be null. It must be generated outside of this component. In this sample app, the link token 31 | // is generated in the link context in client/src/services/link.js. 32 | 33 | export default function LaunchLink(props: Props) { 34 | const history = useHistory(); 35 | const { getItemsByUser, getItemById } = useItems(); 36 | const { generateLinkToken, deleteLinkToken } = useLink(); 37 | const { setError, resetError } = useErrors(); 38 | 39 | // define onSuccess, onExit and onEvent functions as configs for Plaid Link creation 40 | const onSuccess = async ( 41 | publicToken: string, 42 | metadata: PlaidLinkOnSuccessMetadata 43 | ) => { 44 | // log and save metatdata 45 | logSuccess(metadata, props.userId); 46 | if (props.itemId != null) { 47 | // update mode: no need to exchange public token 48 | await setItemState(props.itemId, 'good'); 49 | deleteLinkToken(null, props.itemId); 50 | getItemById(props.itemId, true); 51 | // regular link mode: exchange public token for access token 52 | } else { 53 | // call to Plaid api endpoint: /item/public_token/exchange in order to obtain access_token which is then stored with the created item 54 | await exchangeToken( 55 | publicToken, 56 | metadata.institution, 57 | metadata.accounts, 58 | props.userId 59 | ); 60 | getItemsByUser(props.userId, true); 61 | } 62 | resetError(); 63 | deleteLinkToken(props.userId, null); 64 | history.push(`/user/${props.userId}`); 65 | }; 66 | 67 | const onExit = async ( 68 | error: PlaidLinkError | null, 69 | metadata: PlaidLinkOnExitMetadata 70 | ) => { 71 | // log and save error and metatdata 72 | logExit(error, metadata, props.userId); 73 | if (error != null && error.error_code === 'INVALID_LINK_TOKEN') { 74 | await generateLinkToken(props.userId, props.itemId); 75 | } 76 | if (error != null) { 77 | setError(error.error_code, error.display_message || error.error_message); 78 | } 79 | // to handle other error codes, see https://plaid.com/docs/errors/ 80 | }; 81 | 82 | const onEvent = async ( 83 | eventName: PlaidLinkStableEvent | string, 84 | metadata: PlaidLinkOnEventMetadata 85 | ) => { 86 | // handle errors in the event end-user does not exit with onExit function error enabled. 87 | if (eventName === 'ERROR' && metadata.error_code != null) { 88 | setError(metadata.error_code, ' '); 89 | } 90 | logEvent(eventName, metadata); 91 | }; 92 | 93 | const config: PlaidLinkOptionsWithLinkToken = { 94 | onSuccess, 95 | onExit, 96 | onEvent, 97 | token: props.token, 98 | }; 99 | 100 | if (props.isOauth) { 101 | config.receivedRedirectUri = window.location.href; // add additional receivedRedirectUri config when handling an OAuth reidrect 102 | } 103 | 104 | const { open, ready } = usePlaidLink(config); 105 | 106 | useEffect(() => { 107 | // initiallizes Link automatically 108 | if (props.isOauth && ready) { 109 | open(); 110 | } else if (ready) { 111 | // regular, non-OAuth case: 112 | // set link token, userId and itemId in local storage for use if needed later by OAuth 113 | 114 | localStorage.setItem( 115 | 'oauthConfig', 116 | JSON.stringify({ 117 | userId: props.userId, 118 | itemId: props.itemId, 119 | token: props.token, 120 | }) 121 | ); 122 | open(); 123 | } 124 | }, [ready, open, props.isOauth, props.userId, props.itemId, props.token]); 125 | 126 | return <>; 127 | } 128 | -------------------------------------------------------------------------------- /client/src/components/LoadingCallout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Callout } from 'plaid-threads/Callout'; 3 | import InlineLink from 'plaid-threads/InlineLink'; 4 | 5 | // Allows user to input their personal assets such as a house or car. 6 | 7 | export default function LoadingCallout() { 8 | const [show, setShow] = useState(false); 9 | 10 | useEffect(() => { 11 | const timerId = setTimeout(() => setShow(true), 8000); 12 | return () => { 13 | clearTimeout(timerId); 14 | }; 15 | }, []); 16 | 17 | return ( 18 | <> 19 | {show && ( 20 | 21 | Transactions webhooks not received. See the{' '} 22 | 23 | {' '} 24 | troubleshooting guide{' '} 25 | {' '} 26 | to learn about receiving transactions webhooks with this sample app. 27 | 28 | )} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /client/src/components/Login.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Modal from 'plaid-threads/Modal'; 3 | import ModalBody from 'plaid-threads/ModalBody'; 4 | import Button from 'plaid-threads/Button'; 5 | import TextInput from 'plaid-threads/TextInput'; 6 | 7 | import { useCurrentUser } from '../services'; 8 | 9 | const Login = () => { 10 | const { login } = useCurrentUser(); 11 | const [show, setShow] = useState(false); 12 | const [value, setValue] = useState(''); 13 | 14 | const handleSubmit = () => { 15 | setShow(false); 16 | login(value); 17 | }; 18 | 19 | return ( 20 |
21 | 24 | setShow(false)}> 25 | <> 26 | setShow(false)} 28 | header="User Login" 29 | isLoading={false} 30 | onClickConfirm={handleSubmit} 31 | confirmText="Submit" 32 | > 33 | setValue(e.currentTarget.value)} 39 | /> 40 | 41 | 42 | 43 |
44 | ); 45 | }; 46 | 47 | export default Login; 48 | -------------------------------------------------------------------------------- /client/src/components/MoreDetails.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from 'react'; 2 | import Menu from 'plaid-threads/Icons/MenuS1'; 3 | import Dropdown from 'plaid-threads/Dropdown'; 4 | import IconButton from 'plaid-threads/IconButton'; 5 | import Touchable from 'plaid-threads/Touchable'; 6 | 7 | import LaunchLink from './LaunchLink.tsx'; 8 | import useOnClickOutside from '../hooks/useOnClickOutside.ts'; 9 | import useLink from '../services/link.tsx'; 10 | 11 | interface Props { 12 | setBadStateShown: boolean; 13 | handleSetBadState: () => void; 14 | userId: number; 15 | itemId: number; 16 | handleDelete: () => void; 17 | } 18 | 19 | // Provides for testing of the ITEM_LOGIN_REQUIRED webhook and Link update mode 20 | export function MoreDetails(props: Props) { 21 | const [menuShown, setmenuShown] = useState(false); 22 | const [token, setToken] = useState(''); 23 | const refToButton = useRef(null); 24 | const refToMenu: React.RefObject = useOnClickOutside({ 25 | callback: () => { 26 | setmenuShown(false); 27 | }, 28 | ignoreRef: refToButton, 29 | }); 30 | 31 | const { generateLinkToken, linkTokens } = useLink(); 32 | 33 | const initiateLink = async () => { 34 | // creates new link token for each item in bad state 35 | // only generate a link token upon a click from enduser to update login; 36 | // if done earlier, it may expire before enduser actually activates link. 37 | await generateLinkToken(props.userId, props.itemId); // itemId is set because link is in update mode 38 | }; 39 | 40 | useEffect(() => { 41 | setToken(linkTokens.byItem[props.itemId]); 42 | }, [linkTokens, props.itemId]); 43 | 44 | // display choice, depending on whether item is in "good" or "bad" state 45 | const linkChoice = props.setBadStateShown ? ( 46 | // handleSetBadState uses sandbox/item/reset_login to send the ITEM_LOGIN_REQUIRED webhook; 47 | // app responds to this webhook by setting item to "bad" state (server/webhookHandlers/handleItemWebhook.js) 48 | 49 | Test Item Login Required Webhook 50 | 51 | ) : ( 52 | // item is in "bad" state; launch link to login and return to "good" state 53 |
54 | 55 | Update Login 56 | 57 | {token != null && token.length > 0 && ( 58 | 59 | )} 60 |
61 | ); 62 | 63 | const icon = ( 64 |
65 | } 68 | onClick={() => setmenuShown(!menuShown)} 69 | /> 70 |
71 | ); 72 | 73 | return ( 74 |
75 | 76 | {linkChoice} 77 | 78 | 79 | Remove 80 | 81 | 82 |
83 | ); 84 | } 85 | 86 | export default MoreDetails; 87 | -------------------------------------------------------------------------------- /client/src/components/NetWorth.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import IconButton from 'plaid-threads/IconButton'; 3 | import Trash from 'plaid-threads/Icons/Trash'; 4 | 5 | import { currencyFilter, pluralize } from '../util/index.tsx'; 6 | import Asset from './Asset.tsx'; 7 | import { AccountType, AssetType } from './types'; 8 | import useAssets from '../services/assets.tsx'; 9 | interface Props { 10 | numOfItems: number; 11 | accounts: AccountType[]; 12 | personalAssets: AssetType[]; 13 | userId: number; 14 | assetsOnly: boolean; 15 | } 16 | 17 | export default function NetWorth(props: Props) { 18 | const { deleteAssetByAssetId } = useAssets(); 19 | // sums of account types 20 | const addAllAccounts = ( 21 | accountSubtypes: Array 22 | ): number => 23 | props.accounts 24 | .filter(a => accountSubtypes.includes(a.subtype)) 25 | .reduce((acc: number, val: AccountType) => acc + val.current_balance, 0); 26 | 27 | const depository: number = addAllAccounts([ 28 | 'checking', 29 | 'savings', 30 | 'cd', 31 | 'money market', 32 | ]); 33 | const investment: number = addAllAccounts(['ira', '401k']); 34 | const loan: number = addAllAccounts(['student', 'mortgage']); 35 | const credit: number = addAllAccounts(['credit card']); 36 | 37 | const personalAssetValue = props.personalAssets.reduce((a, b) => { 38 | return a + b.value; 39 | }, 0); 40 | 41 | const assets = depository + investment + personalAssetValue; 42 | const liabilities = loan + credit; 43 | 44 | const handleDelete = (assetId: number, userId: number) => { 45 | deleteAssetByAssetId(assetId, userId); 46 | }; 47 | 48 | return ( 49 |
50 |

Net Worth

51 |

52 | A summary of your assets and liabilities 53 |

54 | {!props.assetsOnly && ( 55 | <> 56 |
{`Your total across ${ 57 | props.numOfItems 58 | } bank ${pluralize('account', props.numOfItems)}`}
59 |

60 | {currencyFilter(assets - liabilities)} 61 |

62 |
63 |
64 |
65 |
66 |

{currencyFilter(assets)}

67 | 68 |
69 | 70 |
71 | {/* 3 columns */} 72 |

Assets

73 |

{''}

74 |

{''}

75 |

Cash

{' '} 76 |

{currencyFilter(depository)}

77 |

{''}

78 |

Investment

79 |

{currencyFilter(investment)}

80 |

{''}

81 |
82 |
83 | {props.personalAssets.map(asset => ( 84 |
85 |

{asset.description}

86 |

{currencyFilter(asset.value)}

87 |

88 | } 91 | onClick={() => { 92 | handleDelete(asset.id, props.userId); 93 | }} 94 | /> 95 |

96 |
97 | ))} 98 |
99 |
100 |
101 |
102 |
103 |

104 | {currencyFilter(liabilities)} 105 |

106 | 107 |
108 | {/* 3 columns */} 109 |

Liabilities

110 |

{''}

111 |

{''}

112 |

Credit Cards

{' '} 113 |

{currencyFilter(credit)}

114 |

{''}

115 |

Loans

116 |

{currencyFilter(loan)}

117 |

{''}

118 |
119 |
120 |
121 |
122 | 123 | )} 124 | {props.assetsOnly && ( 125 | <> 126 |

127 | {currencyFilter(assets - liabilities)} 128 |

129 |
130 |
131 |
132 |
133 |

{currencyFilter(assets)}

134 | 135 |
136 |
137 |

Assets

138 |
139 |
140 | {props.personalAssets.map(asset => ( 141 |
142 |

{asset.description}

143 |

{currencyFilter(asset.value)}

144 |

145 | } 148 | onClick={() => { 149 | handleDelete(asset.id, props.userId); 150 | }} 151 | /> 152 |

153 |
154 | ))} 155 |
156 |
157 |
158 |
159 | 160 | )} 161 |
162 | ); 163 | } 164 | -------------------------------------------------------------------------------- /client/src/components/OAuthLink.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | import LaunchLink from './LaunchLink.tsx'; 4 | 5 | // Component rendered when user is redirected back to site from Oauth institution site. 6 | // It initiates link immediately with the original link token that was set in local storage 7 | // from the initial link initialization. 8 | const OAuthLink = () => { 9 | const [token, setToken] = useState(); 10 | const [userId, setUserId] = useState(-100); // set for typescript 11 | const [itemId, setItemId] = useState(); 12 | 13 | const oauthObject = localStorage.getItem('oauthConfig'); 14 | 15 | useEffect(() => { 16 | if (oauthObject != null) { 17 | setUserId(JSON.parse(oauthObject).userId); 18 | setItemId(JSON.parse(oauthObject).itemId); 19 | setToken(JSON.parse(oauthObject).token); 20 | } 21 | }, [oauthObject]); 22 | 23 | return ( 24 | <> 25 | {token != null && ( 26 | 32 | )} 33 | 34 | ); 35 | }; 36 | 37 | export default OAuthLink; 38 | -------------------------------------------------------------------------------- /client/src/components/Sockets.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import { toast } from 'react-toastify'; 3 | 4 | import { useAccounts, useItems, useTransactions } from '../services'; 5 | const io = require('socket.io-client'); 6 | const { REACT_APP_SERVER_PORT } = process.env; 7 | 8 | export default function Sockets() { 9 | const socket = useRef(); 10 | const { getAccountsByItem } = useAccounts(); 11 | const { getTransactionsByItem } = useTransactions(); 12 | const { getItemById } = useItems(); 13 | 14 | useEffect(() => { 15 | socket.current = io(`http://localhost:${REACT_APP_SERVER_PORT}`); 16 | 17 | socket.current.on('SYNC_UPDATES_AVAILABLE', ({ itemId } = {}) => { 18 | const msg = `New Webhook Event: Item ${itemId}: Transactions updates`; 19 | console.log(msg); 20 | toast(msg); 21 | getAccountsByItem(itemId); 22 | getTransactionsByItem(itemId); 23 | }); 24 | 25 | socket.current.on('ERROR', ({ itemId, errorCode } = {}) => { 26 | const msg = `New Webhook Event: Item ${itemId}: Item Error ${errorCode}`; 27 | console.error(msg); 28 | toast.error(msg); 29 | getItemById(itemId, true); 30 | }); 31 | 32 | socket.current.on('PENDING_EXPIRATION', ({ itemId } = {}) => { 33 | const msg = `New Webhook Event: Item ${itemId}: Access consent is expiring in 7 days. To prevent this, User should re-enter login credentials.`; 34 | console.log(msg); 35 | toast(msg); 36 | getItemById(itemId, true); 37 | }); 38 | 39 | socket.current.on('PENDING_DISCONNECT', ({ itemId } = {}) => { 40 | const msg = `New Webhook Event: Item ${itemId}: Item will be disconnected in 7 days. To prevent this, User should re-enter login credentials.`; 41 | console.log(msg); 42 | toast(msg); 43 | getItemById(itemId, true); 44 | }); 45 | 46 | 47 | 48 | socket.current.on('NEW_TRANSACTIONS_DATA', ({ itemId } = {}) => { 49 | getAccountsByItem(itemId); 50 | getTransactionsByItem(itemId); 51 | }); 52 | 53 | return () => { 54 | socket.current.removeAllListeners(); 55 | socket.current.close(); 56 | }; 57 | }, [getAccountsByItem, getTransactionsByItem, getItemById]); 58 | 59 | return
; 60 | } 61 | -------------------------------------------------------------------------------- /client/src/components/SpendingInsights.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | import { currencyFilter, pluralize } from '../util/index.tsx'; 4 | import CategoriesChart from './CategoriesChart.tsx'; 5 | import { TransactionType } from './types'; 6 | 7 | interface Props { 8 | transactions: TransactionType[]; 9 | numOfItems: number; 10 | } 11 | 12 | interface Categories { 13 | [key: string]: number; 14 | } 15 | 16 | export default function SpendingInsights(props: Props) { 17 | // grab transactions from most recent month and filter out transfers and payments 18 | const transactions = props.transactions; 19 | const monthlyTransactions = useMemo( 20 | () => 21 | transactions.filter(tx => { 22 | const date = new Date(tx.date); 23 | const today = new Date(); 24 | const oneMonthAgo = new Date(new Date().setDate(today.getDate() - 30)); 25 | return ( 26 | date > oneMonthAgo && 27 | tx.category !== 'Payment' && 28 | tx.category !== 'Transfer' && 29 | tx.category !== 'Interest' 30 | ); 31 | }), 32 | [transactions] 33 | ); 34 | 35 | // create category and name objects from transactions 36 | 37 | const categoriesObject = useMemo((): Categories => { 38 | return monthlyTransactions.reduce((obj: Categories, tx) => { 39 | tx.category in obj 40 | ? (obj[tx.category] = tx.amount + obj[tx.category]) 41 | : (obj[tx.category] = tx.amount); 42 | return obj; 43 | }, {}); 44 | }, [monthlyTransactions]); 45 | 46 | const namesObject = useMemo((): Categories => { 47 | return monthlyTransactions.reduce((obj: Categories, tx) => { 48 | tx.name in obj 49 | ? (obj[tx.name] = tx.amount + obj[tx.name]) 50 | : (obj[tx.name] = tx.amount); 51 | return obj; 52 | }, {}); 53 | }, [monthlyTransactions]); 54 | 55 | // sort names by spending totals 56 | const sortedNames = useMemo(() => { 57 | const namesArray = []; 58 | for (const name in namesObject) { 59 | namesArray.push([name, namesObject[name]]); 60 | } 61 | namesArray.sort((a: any[], b: any[]) => b[1] - a[1]); 62 | namesArray.splice(5); // top 5 63 | return namesArray; 64 | }, [namesObject]); 65 | 66 | return ( 67 |
68 |

Monthly Spending

69 |

A breakdown of your monthly spending

70 |
{`Monthly breakdown across ${ 71 | props.numOfItems 72 | } bank ${pluralize('account', props.numOfItems)}`}
73 |
74 |
75 | 76 |
77 |
78 |
79 |

Top 5 Vendors

80 |
81 |

Vendor

Amount

82 | {sortedNames.map((vendor: any[]) => ( 83 | <> 84 |

{vendor[0]}

85 |

{currencyFilter(vendor[1])}

86 | 87 | ))} 88 |
89 |
90 |
91 |
92 |
93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /client/src/components/TransactionsTable.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { currencyFilter } from '../util/index.tsx'; 4 | import { TransactionType } from './types'; 5 | 6 | interface Props { 7 | transactions: TransactionType[]; 8 | } 9 | 10 | export default function TransactionsTable(props: Props) { 11 | return ( 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {props.transactions.map(tx => ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | ))} 31 | 32 |
NameCategoryAmountDate
{tx.name}{tx.category}{currencyFilter(tx.amount)}{tx.date.slice(0, 10)}
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /client/src/components/UserCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { HashLink } from 'react-router-hash-link'; 3 | import Button from 'plaid-threads/Button'; 4 | import Touchable from 'plaid-threads/Touchable'; 5 | 6 | import UserDetails from './UserDetails.tsx'; 7 | import LaunchLink from './LaunchLink.tsx'; 8 | 9 | import useItems from '../services/items.tsx' 10 | import useUsers from '../services/users.tsx'; 11 | import useLink from '../services/link.tsx'; 12 | 13 | import { UserType } from './types'; 14 | 15 | interface Props { 16 | user: UserType; 17 | removeButton: boolean; 18 | linkButton: boolean; 19 | userId: number; 20 | } 21 | 22 | export default function UserCard(props: Props) { 23 | const [numOfItems, setNumOfItems] = useState(0); 24 | const [token, setToken] = useState(''); 25 | const [hovered, setHovered] = useState(false); 26 | const { itemsByUser, getItemsByUser } = useItems(); 27 | const { deleteUserById } = useUsers(); 28 | const { generateLinkToken, linkTokens } = useLink(); 29 | 30 | const initiateLink = async () => { 31 | // only generate a link token upon a click from enduser to add a bank; 32 | // if done earlier, it may expire before enduser actually activates Link to add a bank. 33 | await generateLinkToken(props.userId, null); 34 | }; 35 | 36 | // update data store with the user's items 37 | useEffect(() => { 38 | if (props.userId) { 39 | getItemsByUser(props.userId, true); 40 | } 41 | }, [getItemsByUser, props.userId]); 42 | 43 | // update no of items from data store 44 | useEffect(() => { 45 | if (itemsByUser[props.userId] != null) { 46 | setNumOfItems(itemsByUser[props.userId].length); 47 | } else { 48 | setNumOfItems(0); 49 | } 50 | }, [itemsByUser, props.userId]); 51 | 52 | useEffect(() => { 53 | setToken(linkTokens.byUser[props.userId]); 54 | }, [linkTokens, props.userId, numOfItems]); 55 | 56 | const handleDeleteUser = () => { 57 | deleteUserById(props.user.id); // this will delete all items associated with a user 58 | }; 59 | return ( 60 |
61 |
62 |
{ 65 | if (numOfItems > 0) { 66 | setHovered(true); 67 | } 68 | }} 69 | onMouseLeave={() => { 70 | setHovered(false); 71 | }} 72 | > 73 | 78 |
79 | 84 |
85 |
86 |
87 | {(props.removeButton || (props.linkButton && numOfItems === 0)) && ( 88 |
89 | {props.linkButton && numOfItems === 0 && ( 90 | 98 | )} 99 | {token != null && 100 | token.length > 0 && 101 | props.linkButton && 102 | numOfItems === 0 && ( 103 | 104 | )} 105 | {props.removeButton && ( 106 | 116 | )} 117 |
118 | )} 119 |
120 |
121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /client/src/components/UserDetails.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { pluralize } from '../util/index.tsx'; 3 | 4 | import { formatDate } from '../util/index.tsx'; 5 | import { UserType } from './types'; 6 | 7 | interface Props { 8 | user: UserType; 9 | numOfItems: number; 10 | hovered: boolean; 11 | } 12 | 13 | const UserDetails = (props: Props) => ( 14 | <> 15 |
16 |

User name

17 |

{props.user.username}

18 |
19 |
20 |

Created on

21 |

{formatDate(props.user.created_at)}

22 |
23 |
24 |

Number of banks connected

25 |

26 | {props.hovered ? 'View ' : ''}{' '} 27 | {`${props.numOfItems} ${pluralize('bank', props.numOfItems)}`} 28 |

29 |
30 | 31 | ); 32 | 33 | export default UserDetails; 34 | -------------------------------------------------------------------------------- /client/src/components/UserList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | import useUsers from '../services/users.tsx'; 4 | import UserCard from './UserCard.tsx'; 5 | import { UserType } from './types'; 6 | // This provides developers with a view of all users, and ability to delete a user. 7 | // View at path: "/admin" 8 | const UserList = () => { 9 | const { allUsers, getUsers, usersById } = useUsers(); 10 | const [users, setUsers] = useState([]); 11 | 12 | useEffect(() => { 13 | getUsers(true); 14 | }, [getUsers]); 15 | 16 | useEffect(() => { 17 | setUsers(allUsers); 18 | }, [allUsers, usersById]); 19 | 20 | return ( 21 | <> 22 |
For developer admin use
23 | 24 |
25 | {users.map(user => ( 26 |
27 | 33 |
34 | ))} 35 |
36 | 37 | ); 38 | }; 39 | 40 | export default UserList; 41 | -------------------------------------------------------------------------------- /client/src/components/UserPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Link, RouteComponentProps } from 'react-router-dom'; 3 | import sortBy from 'lodash/sortBy'; 4 | import NavigationLink from 'plaid-threads/NavigationLink'; 5 | import LoadingSpinner from 'plaid-threads/LoadingSpinner'; 6 | import Callout from 'plaid-threads/Callout'; 7 | import Button from 'plaid-threads/Button'; 8 | 9 | import { RouteInfo, ItemType, AccountType, AssetType } from './types'; 10 | 11 | import useItems from '../services/items.tsx'; 12 | import useTransactions from '../services/transactions.tsx'; 13 | import useUsers from '../services/users.tsx'; 14 | import useAssets from '../services/assets.tsx'; 15 | import useLink from '../services/link.tsx'; 16 | import useAccounts from '../services/accounts.tsx'; 17 | 18 | import { pluralize } from '../util/index.tsx'; 19 | 20 | import Banner from './Banner.tsx'; 21 | import LaunchLink from './LaunchLink.tsx'; 22 | import SpendingInsights from './SpendingInsights.tsx'; 23 | import NetWorth from './NetWorth.tsx'; 24 | import ItemCard from './ItemCard.tsx'; 25 | import UserCard from './UserCard.tsx'; 26 | import LoadingCallout from './LoadingCallout.tsx'; 27 | import ErrorMessage from './ErrorMessage.tsx'; 28 | 29 | 30 | // provides view of user's net worth, spending by category and allows them to explore 31 | // account and transactions details for linked items 32 | 33 | const UserPage = ({ match }: RouteComponentProps) => { 34 | const [user, setUser] = useState({ 35 | id: 0, 36 | username: '', 37 | created_at: '', 38 | updated_at: '', 39 | }); 40 | const [items, setItems] = useState([]); 41 | const [token, setToken] = useState(''); 42 | const [numOfItems, setNumOfItems] = useState(0); 43 | const [transactions, setTransactions] = useState([]); 44 | const [accounts, setAccounts] = useState([]); 45 | const [assets, setAssets] = useState([]); 46 | 47 | const { getTransactionsByUser, transactionsByUser } = useTransactions(); 48 | const { getAccountsByUser, accountsByUser } = useAccounts(); 49 | const { assetsByUser, getAssetsByUser } = useAssets(); 50 | const { usersById, getUserById } = useUsers(); 51 | const { itemsByUser, getItemsByUser } = useItems(); 52 | const userId = Number(match.params.userId); 53 | const { generateLinkToken, linkTokens } = useLink(); 54 | 55 | const initiateLink = async () => { 56 | // only generate a link token upon a click from enduser to add a bank; 57 | // if done earlier, it may expire before enduser actually activates Link to add a bank. 58 | await generateLinkToken(userId, null); 59 | }; 60 | 61 | // update data store with user 62 | useEffect(() => { 63 | getUserById(userId, false); 64 | }, [getUserById, userId]); 65 | 66 | // set state user from data store 67 | useEffect(() => { 68 | setUser(usersById[userId] || {}); 69 | }, [usersById, userId]); 70 | 71 | useEffect(() => { 72 | // This gets transactions from the database only. 73 | // Note that calls to Plaid's transactions/get endpoint are only made in response 74 | // to receipt of a transactions webhook. 75 | getTransactionsByUser(userId); 76 | }, [getTransactionsByUser, userId]); 77 | 78 | useEffect(() => { 79 | setTransactions(transactionsByUser[userId] || []); 80 | }, [transactionsByUser, userId]); 81 | 82 | // update data store with the user's assets 83 | useEffect(() => { 84 | getAssetsByUser(userId); 85 | }, [getAssetsByUser, userId]); 86 | 87 | useEffect(() => { 88 | setAssets(assetsByUser.assets || []); 89 | }, [assetsByUser, userId]); 90 | 91 | // update data store with the user's items 92 | useEffect(() => { 93 | if (userId != null) { 94 | getItemsByUser(userId, true); 95 | } 96 | }, [getItemsByUser, userId]); 97 | 98 | // update state items from data store 99 | useEffect(() => { 100 | const newItems: Array = itemsByUser[userId] || []; 101 | const orderedItems = sortBy( 102 | newItems, 103 | item => new Date(item.updated_at) 104 | ).reverse(); 105 | setItems(orderedItems); 106 | }, [itemsByUser, userId]); 107 | 108 | // update no of items from data store 109 | useEffect(() => { 110 | if (itemsByUser[userId] != null) { 111 | setNumOfItems(itemsByUser[userId].length); 112 | } else { 113 | setNumOfItems(0); 114 | } 115 | }, [itemsByUser, userId]); 116 | 117 | // update data store with the user's accounts 118 | useEffect(() => { 119 | getAccountsByUser(userId); 120 | }, [getAccountsByUser, userId]); 121 | 122 | useEffect(() => { 123 | setAccounts(accountsByUser[userId] || []); 124 | }, [accountsByUser, userId]); 125 | 126 | useEffect(() => { 127 | setToken(linkTokens.byUser[userId]); 128 | }, [linkTokens, userId, numOfItems]); 129 | 130 | document.getElementsByTagName('body')[0].style.overflow = 'auto'; // to override overflow:hidden from link pane 131 | return ( 132 |
133 | 134 | BACK TO LOGIN 135 | 136 | 137 | 138 | {linkTokens.error.error_code != null && ( 139 | 140 |
141 | Unable to fetch link_token: please make sure your backend server is 142 | running and that your .env file has been configured correctly. 143 |
144 |
145 | Error Code: {linkTokens.error.error_code} 146 |
147 |
148 | Error Type: {linkTokens.error.error_type}{' '} 149 |
150 |
Error Message: {linkTokens.error.error_message}
151 |
152 | )} 153 | 154 | {numOfItems === 0 && } 155 | {numOfItems > 0 && transactions.length === 0 && ( 156 |
157 | 158 | 159 |
160 | )} 161 | {numOfItems > 0 && transactions.length > 0 && ( 162 | <> 163 | 170 | 174 | 175 | )} 176 | {numOfItems === 0 && transactions.length === 0 && assets.length > 0 && ( 177 | <> 178 | 185 | 186 | )} 187 | {numOfItems > 0 && ( 188 | <> 189 |
190 |
191 |

192 | {`${items.length} ${pluralize('Bank', items.length)} Linked`} 193 |

194 | {!!items.length && ( 195 |

196 | Below is a list of all your connected banks. Click on a bank 197 | to view its associated accounts. 198 |

199 | )} 200 |
201 | 202 | 210 | 211 | {token != null && token.length > 0 && ( 212 | // Link will not render unless there is a link token 213 | 214 | )} 215 |
216 | 217 | {items.map(item => ( 218 |
219 | 220 |
221 | ))} 222 | 223 | )} 224 |
225 | ); 226 | }; 227 | 228 | export default UserPage; 229 | -------------------------------------------------------------------------------- /client/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AccountCard } from './AccountCard'; 2 | export { default as AddUserForm } from './AddUserForm'; 3 | export { default as Banner } from './Banner'; 4 | export { default as DuplicateItemToastMessage } from './DuplicateItemToast'; 5 | export { default as ItemCard } from './ItemCard'; 6 | export { default as UserPage } from './UserPage'; 7 | export { default as Landing } from './Landing'; 8 | export { default as LaunchLink } from './LaunchLink'; 9 | export { default as MoreDetails } from './MoreDetails'; 10 | export { default as Sockets } from './Sockets'; 11 | export { default as SpendingInsights } from './SpendingInsights'; 12 | export { default as TransactionsTable } from './TransactionsTable'; 13 | export { default as UserCard } from './UserCard'; 14 | export { default as UserDetails } from './UserDetails'; 15 | export { default as UserList } from './UserList'; 16 | export { default as OAuthLink } from './OAuthLink'; 17 | export { default as Login } from './Login'; 18 | export { default as CategoriesChart } from './CategoriesChart'; 19 | export { default as NetWorth } from './NetWorth'; 20 | export { default as Asset } from './Asset'; 21 | export { default as LoadingCallout } from './LoadingCallout'; 22 | export { default as ErrorMessage } from './ErrorMessage'; 23 | -------------------------------------------------------------------------------- /client/src/components/types.ts: -------------------------------------------------------------------------------- 1 | // reserved for types 2 | 3 | export interface RouteInfo { 4 | userId: string; 5 | } 6 | 7 | export interface ItemType { 8 | id: number; 9 | plaid_item_id: string; 10 | user_id: number; 11 | plaid_access_token: string; 12 | plaid_institution_id: string; 13 | status: string; 14 | created_at: string; 15 | updated_at: string; 16 | } 17 | 18 | export interface AccountType { 19 | id: number; 20 | item_id: number; 21 | user_id: number; 22 | plaid_account_id: string; 23 | name: string; 24 | mask: string; 25 | official_name: string; 26 | current_balance: number; 27 | available_balance: number; 28 | iso_currency_code: string; 29 | unofficial_currency_code: string; 30 | type: 'depository' | 'investment' | 'loan' | 'credit'; 31 | subtype: 32 | | 'checking' 33 | | 'savings' 34 | | 'cd' 35 | | 'money market' 36 | | 'ira' 37 | | '401k' 38 | | 'student' 39 | | 'mortgage' 40 | | 'credit card'; 41 | created_at: string; 42 | updated_at: string; 43 | } 44 | export interface TransactionType { 45 | id: number; 46 | account_id: number; 47 | item_id: number; 48 | user_id: number; 49 | plaid_transaction_id: string; 50 | plaid_category_id: string; 51 | category: string; 52 | type: string; 53 | name: string; 54 | amount: number; 55 | iso_currency_code: string; 56 | unofficial_currency_code: string; 57 | date: string; 58 | pending: boolean; 59 | account_owner: string; 60 | created_at: string; 61 | updated_at: string; 62 | } 63 | 64 | export interface AssetType { 65 | id: number; 66 | user_id: number; 67 | value: number; 68 | description: string; 69 | created_at: string; 70 | updated_at: string; 71 | } 72 | 73 | export interface UserType { 74 | id: number; 75 | username: string | null; 76 | created_at: string; 77 | updated_at: string; 78 | } 79 | -------------------------------------------------------------------------------- /client/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useBoolean } from './useBoolean'; 2 | export { default as useOnClickOutside } from './useOnClickOutside'; 3 | -------------------------------------------------------------------------------- /client/src/hooks/useBoolean.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | 3 | export default function useBoolean(initial: any) { 4 | const [state, setState] = useState(initial); 5 | 6 | const setFalse = useCallback(() => { 7 | setState(false); 8 | }, []); 9 | const toggle = useCallback(() => { 10 | setState((prev: any) => !prev); 11 | }, []); 12 | 13 | return [state, setFalse, toggle]; 14 | } 15 | -------------------------------------------------------------------------------- /client/src/hooks/useOnClickOutside.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | 3 | interface Props { 4 | callback: () => void; 5 | ignoreRef: React.RefObject; 6 | } 7 | 8 | export default function useOnClickOutside(props: Props) { 9 | const ref = useRef(null); 10 | 11 | useEffect(() => { 12 | const listener = (event: any) => { 13 | if (!ref.current || ref.current.contains(event.target)) { 14 | return; 15 | } 16 | 17 | if ( 18 | !props.ignoreRef.current || 19 | props.ignoreRef.current.contains(event.target) 20 | ) { 21 | return; 22 | } 23 | 24 | props.callback(); 25 | }; 26 | 27 | document.addEventListener('mousedown', listener); 28 | document.addEventListener('touchstart', listener); 29 | 30 | return () => { 31 | document.removeEventListener('mousedown', listener); 32 | document.removeEventListener('touchstart', listener); 33 | }; 34 | }, [props.callback, props.ignoreRef]); 35 | 36 | return ref; 37 | } 38 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Router as BrowserRouter } from 'react-router-dom'; 4 | import { createBrowserHistory } from 'history'; 5 | 6 | import './index.scss'; 7 | import App from './App.tsx'; 8 | 9 | const history = createBrowserHistory(); 10 | 11 | ReactDOM.render( 12 | 13 | 14 | , 15 | document.getElementById('root') 16 | ); 17 | -------------------------------------------------------------------------------- /client/src/index.scss: -------------------------------------------------------------------------------- 1 | $threads-font-path: '~plaid-threads/fonts'; 2 | @import '~plaid-threads/scss/typography'; 3 | @import '~plaid-threads/scss/variables'; 4 | 5 | body { 6 | margin: 0; 7 | padding: 0; 8 | } 9 | -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/services/accounts.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useContext, 4 | useMemo, 5 | useReducer, 6 | useCallback, 7 | Dispatch, 8 | ReactNode, 9 | } from 'react'; 10 | import groupBy from 'lodash/groupBy'; 11 | import keyBy from 'lodash/keyBy'; 12 | import omitBy from 'lodash/omitBy'; 13 | import { AccountType } from '../components/types'; 14 | 15 | import { 16 | getAccountsByItem as apiGetAccountsByItem, 17 | getAccountsByUser as apiGetAccountsByUser, 18 | } from './api.tsx'; 19 | 20 | interface AccountsState { 21 | [accountId: number]: AccountType; 22 | } 23 | 24 | const initialState: AccountsState = {}; 25 | type AccountsAction = 26 | | { 27 | type: 'SUCCESSFUL_GET'; 28 | payload: AccountType[]; 29 | } 30 | | { type: 'DELETE_BY_ITEM'; payload: number } 31 | | { type: 'DELETE_BY_USER'; payload: number }; 32 | 33 | interface AccountsContextShape extends AccountsState { 34 | dispatch: Dispatch; 35 | accountsByItem: { [itemId: number]: AccountType[] }; 36 | deleteAccountsByItemId: (itemId: number) => void; 37 | getAccountsByUser: (userId: number) => void; 38 | accountsByUser: { [user_id: number]: AccountType[] }; 39 | deleteAccountsByUserId: (userId: number) => void; 40 | } 41 | const AccountsContext = createContext( 42 | initialState as AccountsContextShape 43 | ); 44 | 45 | /** 46 | * @desc Maintains the Accounts context state and provides functions to update that state. 47 | */ 48 | export const AccountsProvider: React.FC<{ children: ReactNode }> = ( 49 | props: any 50 | ) => { 51 | const [accountsById, dispatch] = useReducer(reducer, initialState); 52 | 53 | /** 54 | * @desc Requests all Accounts that belong to an individual Item. 55 | */ 56 | const getAccountsByItem = useCallback(async itemId => { 57 | const { data: payload } = await apiGetAccountsByItem(itemId); 58 | dispatch({ type: 'SUCCESSFUL_GET', payload: payload }); 59 | }, []); 60 | 61 | /** 62 | * @desc Requests all Accounts that belong to an individual User. 63 | */ 64 | const getAccountsByUser = useCallback(async userId => { 65 | const { data: payload } = await apiGetAccountsByUser(userId); 66 | dispatch({ type: 'SUCCESSFUL_GET', payload: payload }); 67 | }, []); 68 | 69 | /** 70 | * @desc Will delete all accounts that belong to an individual Item. 71 | * There is no api request as apiDeleteItemById in items delete all related transactions 72 | */ 73 | const deleteAccountsByItemId = useCallback(itemId => { 74 | dispatch({ type: 'DELETE_BY_ITEM', payload: itemId }); 75 | }, []); 76 | 77 | /** 78 | * @desc Will delete all accounts that belong to an individual User. 79 | * There is no api request as apiDeleteItemById in items delete all related transactions 80 | */ 81 | const deleteAccountsByUserId = useCallback(userId => { 82 | dispatch({ type: 'DELETE_BY_USER', payload: userId }); 83 | }, []); 84 | 85 | /** 86 | * @desc Builds a more accessible state shape from the Accounts data. useMemo will prevent 87 | * these from being rebuilt on every render unless accountsById is updated in the reducer. 88 | */ 89 | const value = useMemo(() => { 90 | const allAccounts = Object.values(accountsById); 91 | 92 | return { 93 | allAccounts, 94 | accountsById, 95 | accountsByItem: groupBy(allAccounts, 'item_id'), 96 | accountsByUser: groupBy(allAccounts, 'user_id'), 97 | getAccountsByItem, 98 | getAccountsByUser, 99 | deleteAccountsByItemId, 100 | deleteAccountsByUserId, 101 | }; 102 | }, [ 103 | accountsById, 104 | getAccountsByItem, 105 | getAccountsByUser, 106 | deleteAccountsByItemId, 107 | deleteAccountsByUserId, 108 | ]); 109 | 110 | return ; 111 | }; 112 | 113 | /** 114 | * @desc Handles updates to the Accounts state as dictated by dispatched actions. 115 | */ 116 | function reducer(state: AccountsState, action: AccountsAction) { 117 | switch (action.type) { 118 | case 'SUCCESSFUL_GET': 119 | if (!action.payload.length) { 120 | return state; 121 | } 122 | return { 123 | ...state, 124 | ...keyBy(action.payload, 'id'), 125 | }; 126 | case 'DELETE_BY_ITEM': 127 | return omitBy( 128 | state, 129 | transaction => transaction.item_id === action.payload 130 | ); 131 | case 'DELETE_BY_USER': 132 | return omitBy( 133 | state, 134 | transaction => transaction.user_id === action.payload 135 | ); 136 | default: 137 | console.warn('unknown action'); 138 | return state; 139 | } 140 | } 141 | 142 | /** 143 | * @desc A convenience hook to provide access to the Accounts context state in components. 144 | */ 145 | export default function useAccounts() { 146 | const context = useContext(AccountsContext); 147 | 148 | if (!context) { 149 | throw new Error(`useAccounts must be used within an AccountsProvider`); 150 | } 151 | 152 | return context; 153 | } 154 | -------------------------------------------------------------------------------- /client/src/services/api.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import React from 'react'; 3 | import { toast } from 'react-toastify'; 4 | import { PlaidLinkOnSuccessMetadata } from 'react-plaid-link'; 5 | 6 | import DuplicateItemToastMessage from '../components/DuplicateItemToast.tsx'; 7 | 8 | const baseURL = '/'; 9 | 10 | const api = axios.create({ 11 | baseURL, 12 | headers: { 13 | 'Cache-Control': 'no-cache, no-store, must-revalidate', 14 | Pragma: 'no-cache', 15 | Expires: 0, 16 | }, 17 | }); 18 | 19 | export default api; 20 | // currentUser 21 | export const getLoginUser = (username: string) => 22 | api.post('/sessions', { username }); 23 | 24 | // assets 25 | export const addAsset = (userId: number, description: string, value: number) => 26 | api.post('/assets', { userId, description, value }); 27 | export const getAssetsByUser = (userId: number) => api.get(`/assets/${userId}`); 28 | export const deleteAssetByAssetId = (assetId: number) => 29 | api.delete(`/assets/${assetId}`); 30 | 31 | // users 32 | export const getUsers = () => api.get('/users'); 33 | export const getUserById = (userId: number) => api.get(`/users/${userId}`); 34 | export const addNewUser = (username: string) => 35 | api.post('/users', { username }); 36 | export const deleteUserById = (userId: number) => 37 | api.delete(`/users/${userId}`); 38 | 39 | // items 40 | export const getItemById = (id: number) => api.get(`/items/${id}`); 41 | export const getItemsByUser = (userId: number) => 42 | api.get(`/users/${userId}/items`); 43 | export const deleteItemById = (id: number) => api.delete(`/items/${id}`); 44 | export const setItemState = (itemId: number, status: string) => 45 | api.put(`items/${itemId}`, { status }); 46 | // This endpoint is only availble in the sandbox enviornment 47 | export const setItemToBadState = (itemId: number) => 48 | api.post('/items/sandbox/item/reset_login', { itemId }); 49 | 50 | export const getLinkToken = (userId: number, itemId: number) => 51 | api.post(`/link-token`, { 52 | userId, 53 | itemId, 54 | }); 55 | 56 | // accounts 57 | export const getAccountsByItem = (itemId: number) => 58 | api.get(`/items/${itemId}/accounts`); 59 | export const getAccountsByUser = (userId: number) => 60 | api.get(`/users/${userId}/accounts`); 61 | 62 | // transactions 63 | export const getTransactionsByAccount = (accountId: number) => 64 | api.get(`/accounts/${accountId}/transactions`); 65 | export const getTransactionsByItem = (itemId: number) => 66 | api.get(`/items/${itemId}/transactions`); 67 | export const getTransactionsByUser = (userId: number) => 68 | api.get(`/users/${userId}/transactions`); 69 | 70 | // institutions 71 | export const getInstitutionById = (instId: string) => 72 | api.get(`/institutions/${instId}`); 73 | 74 | // misc 75 | export const postLinkEvent = (event: any) => api.post(`/link-event`, event); 76 | 77 | export const exchangeToken = async ( 78 | publicToken: string, 79 | institution: any, 80 | accounts: PlaidLinkOnSuccessMetadata['accounts'], 81 | userId: number 82 | ) => { 83 | try { 84 | const { data } = await api.post('/items', { 85 | publicToken, 86 | institutionId: institution.institution_id, 87 | userId, 88 | accounts, 89 | }); 90 | return data; 91 | } catch (err) { 92 | const { response } = err; 93 | if (response && response.status === 409) { 94 | toast.error( 95 | 96 | ); 97 | } else { 98 | toast.error(`Error linking ${institution.name}`); 99 | } 100 | } 101 | }; 102 | -------------------------------------------------------------------------------- /client/src/services/assets.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useContext, 4 | useMemo, 5 | useReducer, 6 | useCallback, 7 | Dispatch, 8 | } from 'react'; 9 | import { toast } from 'react-toastify'; 10 | import { addAsset as apiAddAsset } from './api.tsx'; 11 | import { getAssetsByUser as apiGetAssetsByUser } from './api.tsx'; 12 | import { deleteAssetByAssetId as apiDeleteAssetByAssetId } from './api.tsx'; 13 | import { AssetType } from '../components/types'; 14 | 15 | interface AssetsState { 16 | assets: AssetType[] | null; 17 | } 18 | 19 | const initialState = { assets: null }; 20 | 21 | type AssetsAction = 22 | | { 23 | type: 'SUCCESSFUL_GET'; 24 | payload: string; 25 | } 26 | | { type: 'FAILED_GET'; payload: number }; 27 | 28 | interface AssetsContextShape extends AssetsState { 29 | dispatch: Dispatch; 30 | addAsset: (userId: number, description: string, value: number) => void; 31 | assetsByUser: AssetsState; 32 | getAssetsByUser: (userId: number) => void; 33 | deleteAssetByAssetId: (assetId: number, userId: number) => void; 34 | } 35 | const AssetsContext = createContext( 36 | initialState as AssetsContextShape 37 | ); 38 | 39 | /** 40 | * @desc Maintains the Properties context state 41 | */ 42 | export function AssetsProvider(props: any) { 43 | const [assetsByUser, dispatch] = useReducer(reducer, initialState); 44 | 45 | const getAssetsByUser = useCallback(async (userId: number) => { 46 | try { 47 | const { data: payload } = await apiGetAssetsByUser(userId); 48 | if (payload != null) { 49 | dispatch({ type: 'SUCCESSFUL_GET', payload: payload }); 50 | } else { 51 | dispatch({ type: 'FAILED_GET' }); 52 | } 53 | } catch (err) { 54 | console.log(err); 55 | } 56 | }, []); 57 | 58 | const addAsset = useCallback( 59 | async (userId, description, value, refresh) => { 60 | try { 61 | const { data: payload } = await apiAddAsset(userId, description, value); 62 | if (payload != null) { 63 | toast.success(`Successful addition of ${description}`); 64 | await getAssetsByUser(userId); 65 | } else { 66 | toast.error(`Could not add ${description}`); 67 | } 68 | } catch (err) { 69 | console.log(err); 70 | } 71 | }, 72 | [getAssetsByUser] 73 | ); 74 | 75 | const deleteAssetByAssetId = useCallback( 76 | async (assetId, userId) => { 77 | try { 78 | await apiDeleteAssetByAssetId(assetId); 79 | await getAssetsByUser(userId); 80 | } catch (err) { 81 | console.log(err); 82 | } 83 | }, 84 | [getAssetsByUser] 85 | ); 86 | const value = useMemo(() => { 87 | return { 88 | assetsByUser, 89 | addAsset, 90 | getAssetsByUser, 91 | deleteAssetByAssetId, 92 | }; 93 | }, [assetsByUser, addAsset, getAssetsByUser, deleteAssetByAssetId]); 94 | 95 | return ; 96 | } 97 | 98 | /** 99 | * @desc Handles updates to the propertiesByUser as dictated by dispatched actions. 100 | */ 101 | function reducer(state: AssetsState, action: AssetsAction | any) { 102 | switch (action.type) { 103 | case 'SUCCESSFUL_GET': 104 | return { 105 | assets: action.payload, 106 | }; 107 | case 'FAILED_GET': 108 | return { 109 | ...state, 110 | }; 111 | default: 112 | console.warn('unknown action: ', action.type, action.payload); 113 | return state; 114 | } 115 | } 116 | 117 | /** 118 | * @desc A convenience hook to provide access to the Properties context state in components. 119 | */ 120 | export default function useAssets() { 121 | const context = useContext(AssetsContext); 122 | 123 | if (!context) { 124 | throw new Error(`useAssets must be used within a AssetsProvider`); 125 | } 126 | 127 | return context; 128 | } 129 | -------------------------------------------------------------------------------- /client/src/services/currentUser.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useContext, 4 | useMemo, 5 | useReducer, 6 | useCallback, 7 | Dispatch, 8 | } from 'react'; 9 | import { toast } from 'react-toastify'; 10 | import { useHistory } from 'react-router-dom'; 11 | import { getLoginUser as apiGetLoginUser } from './api.tsx'; 12 | import { UserType } from '../components/types'; 13 | 14 | interface CurrentUserState { 15 | currentUser: UserType; 16 | newUser: string | null; 17 | } 18 | 19 | const initialState = { 20 | currentUser: {}, 21 | newUser: null, 22 | }; 23 | type CurrentUserAction = 24 | | { 25 | type: 'SUCCESSFUL_GET'; 26 | payload: UserType; 27 | } 28 | | { type: 'ADD_USER'; payload: string } 29 | | { type: 'FAILED_GET' }; 30 | 31 | interface CurrentUserContextShape extends CurrentUserState { 32 | dispatch: Dispatch; 33 | setNewUser: (username: string) => void; 34 | userState: CurrentUserState; 35 | setCurrentUser: (username: string) => void; 36 | login: (username: string) => void; 37 | } 38 | const CurrentUserContext = createContext( 39 | initialState as CurrentUserContextShape 40 | ); 41 | 42 | /** 43 | * @desc Maintains the currentUser context state and provides functions to update that state 44 | */ 45 | export function CurrentUserProvider(props: any) { 46 | const [userState, dispatch] = useReducer(reducer, initialState); 47 | const history = useHistory(); 48 | 49 | /** 50 | * @desc Requests details for a single User. 51 | */ 52 | const login = useCallback( 53 | async username => { 54 | try { 55 | const { data: payload } = await apiGetLoginUser(username); 56 | if (payload != null) { 57 | toast.success(`Successful login. Welcome back ${username}`); 58 | dispatch({ type: 'SUCCESSFUL_GET', payload: payload[0] }); 59 | history.push(`/user/${payload[0].id}`); 60 | } else { 61 | toast.error(`Username ${username} is invalid. Try again. `); 62 | dispatch({ type: 'FAILED_GET' }); 63 | } 64 | } catch (err) { 65 | console.log(err); 66 | } 67 | }, 68 | [history] 69 | ); 70 | 71 | const setCurrentUser = useCallback( 72 | async username => { 73 | try { 74 | const { data: payload } = await apiGetLoginUser(username); 75 | if (payload != null) { 76 | dispatch({ type: 'SUCCESSFUL_GET', payload: payload[0] }); 77 | history.push(`/user/${payload[0].id}`); 78 | } else { 79 | dispatch({ type: 'FAILED_GET' }); 80 | } 81 | } catch (err) { 82 | console.log(err); 83 | } 84 | }, 85 | [history] 86 | ); 87 | 88 | const setNewUser = useCallback(async username => { 89 | dispatch({ type: 'ADD_USER', payload: username }); 90 | }, []); 91 | 92 | /** 93 | * @desc Builds a more accessible state shape from the Users data. useMemo will prevent 94 | * these from being rebuilt on every render unless usersById is updated in the reducer. 95 | */ 96 | const value = useMemo(() => { 97 | return { 98 | userState, 99 | login, 100 | setCurrentUser, 101 | setNewUser, 102 | }; 103 | }, [userState, login, setCurrentUser, setNewUser]); 104 | 105 | return ; 106 | } 107 | 108 | /** 109 | * @desc Handles updates to the Users state as dictated by dispatched actions. 110 | */ 111 | function reducer(state: CurrentUserState, action: CurrentUserAction | any) { 112 | switch (action.type) { 113 | case 'SUCCESSFUL_GET': 114 | return { 115 | currentUser: action.payload, 116 | newUser: null, 117 | }; 118 | case 'FAILED_GET': 119 | return { 120 | ...state, 121 | newUser: null, 122 | }; 123 | case 'ADD_USER': 124 | return { 125 | ...state, 126 | newUser: action.payload, 127 | }; 128 | default: 129 | console.warn('unknown action: ', action.type, action.payload); 130 | return state; 131 | } 132 | } 133 | 134 | /** 135 | * @desc A convenience hook to provide access to the Users context state in components. 136 | */ 137 | export default function useCurrentUser() { 138 | const context = useContext(CurrentUserContext); 139 | 140 | if (!context) { 141 | throw new Error(`useUsers must be used within a UsersProvider`); 142 | } 143 | 144 | return context; 145 | } 146 | -------------------------------------------------------------------------------- /client/src/services/errors.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useContext, 4 | useMemo, 5 | useReducer, 6 | useCallback, 7 | Dispatch, 8 | ReactNode, 9 | } from 'react'; 10 | 11 | interface ErrorsState { 12 | code?: string; 13 | message?: string; 14 | } 15 | 16 | const initialState: ErrorsState = {}; 17 | type ErrorsAction = 18 | | { 19 | type: 'SET_ERROR'; 20 | payload: ErrorsState; 21 | } 22 | | { 23 | type: 'RESET_ERROR'; 24 | payload: ErrorsState; 25 | }; 26 | 27 | interface ErrorsContextShape extends ErrorsState { 28 | dispatch: Dispatch; 29 | setError: (code: string, message: string | null) => void; 30 | error: { code: string; message: string }; 31 | resetError: () => void; 32 | } 33 | const ErrorsContext = createContext( 34 | initialState as ErrorsContextShape 35 | ); 36 | 37 | /** 38 | * @desc Maintains the Errors context state and provides functions to update that state. 39 | */ 40 | export const ErrorsProvider: React.FC<{ children: ReactNode }> = ( 41 | props: any 42 | ) => { 43 | const [error, dispatch] = useReducer(reducer, initialState); 44 | 45 | /** 46 | * @desc Sets error from onEvent callback. 47 | */ 48 | const setError = useCallback(async (code: string, message: string) => { 49 | dispatch({ 50 | type: 'SET_ERROR', 51 | payload: { code: code, message: message }, 52 | }); 53 | }, []); 54 | 55 | /** 56 | * @desc resets error from onSuccess callback. 57 | */ 58 | const resetError = useCallback(async () => { 59 | dispatch({ 60 | type: 'RESET_ERROR', 61 | payload: {}, 62 | }); 63 | }, []); 64 | 65 | /** 66 | * @desc useMemo will prevent error 67 | * from being rebuilt on every render unless error is updated in the reducer. 68 | */ 69 | const value = useMemo(() => { 70 | return { 71 | setError, 72 | error, 73 | resetError, 74 | }; 75 | }, [setError, error, resetError]); 76 | 77 | return ; 78 | }; 79 | 80 | /** 81 | * @desc Handles updates to the Errors state as dictated by dispatched actions. 82 | */ 83 | function reducer(state: ErrorsState, action: ErrorsAction) { 84 | switch (action.type) { 85 | case 'SET_ERROR': 86 | if (action.payload == null) { 87 | return state; 88 | } 89 | return { 90 | code: action.payload.code, 91 | message: action.payload.message, 92 | }; 93 | case 'RESET_ERROR': 94 | return {}; 95 | 96 | default: 97 | console.warn('unknown action'); 98 | return state; 99 | } 100 | } 101 | 102 | /** 103 | * @desc A convenience hook to provide access to the Errors context state in components. 104 | */ 105 | export default function useErrors() { 106 | const context = useContext(ErrorsContext); 107 | 108 | if (!context) { 109 | throw new Error(`useErrorsmust be used within an ErrorsProvider`); 110 | } 111 | 112 | return context; 113 | } 114 | -------------------------------------------------------------------------------- /client/src/services/index.js: -------------------------------------------------------------------------------- 1 | export { default as api } from './api.tsx'; 2 | export { default as useUsers } from './users.tsx'; 3 | export { default as useCurrentUser } from './currentUser.tsx'; 4 | export { default as useItems } from './items.tsx'; 5 | export { default as useAccounts } from './accounts.tsx'; 6 | export { default as useLink } from './link.tsx'; 7 | export { default as useTransactions } from './transactions.tsx'; 8 | export { default as useInstitutions } from './institutions.tsx'; 9 | export { default as useAssets } from './assets.tsx'; 10 | export { default as useErrors } from './errors.tsx'; 11 | -------------------------------------------------------------------------------- /client/src/services/institutions.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useContext, 4 | useMemo, 5 | useReducer, 6 | useCallback, 7 | Dispatch, 8 | } from 'react'; 9 | import { Institution } from 'plaid/dist/api'; 10 | 11 | import { getInstitutionById as apiGetInstitutionById } from './api.tsx'; 12 | 13 | interface InstitutionsById { 14 | [key: string]: Institution; 15 | } 16 | interface InstitutionsState { 17 | institutionsById: InstitutionsById; 18 | } 19 | 20 | const initialState = {}; 21 | type InstitutionsAction = { 22 | type: 'SUCCESSFUL_GET'; 23 | payload: Institution; 24 | }; 25 | 26 | interface InstitutionsContextShape extends InstitutionsState { 27 | dispatch: Dispatch; 28 | getInstitutionById: (id: string) => void; 29 | formatLogoSrc: (src: string | null | undefined) => string; 30 | } 31 | const InstitutionsContext = createContext( 32 | initialState as InstitutionsContextShape 33 | ); 34 | 35 | /** 36 | * @desc Maintains the Institutions context state and provides functions to update that state. 37 | */ 38 | export function InstitutionsProvider(props: any) { 39 | const [institutionsById, dispatch] = useReducer(reducer, initialState); 40 | 41 | /** 42 | * @desc Requests details for a single Institution. 43 | */ 44 | const getInstitutionById = useCallback(async id => { 45 | const { data: payload } = await apiGetInstitutionById(id); 46 | const institution = payload[0]; 47 | dispatch({ type: 'SUCCESSFUL_GET', payload: institution }); 48 | }, []); 49 | 50 | /** 51 | * @desc Builds a more accessible state shape from the Institution data. useMemo will prevent 52 | * these from being rebuilt on every render unless institutionsById is updated in the reducer. 53 | */ 54 | const value = useMemo(() => { 55 | const allInstitutions = Object.values(institutionsById); 56 | return { 57 | allInstitutions, 58 | institutionsById, 59 | getInstitutionById, 60 | getInstitutionsById: getInstitutionById, 61 | formatLogoSrc, 62 | }; 63 | }, [institutionsById, getInstitutionById]); 64 | 65 | return ; 66 | } 67 | 68 | /** 69 | * @desc Handles updates to the Institutions state as dictated by dispatched actions. 70 | */ 71 | function reducer(state: InstitutionsById, action: InstitutionsAction) { 72 | switch (action.type) { 73 | case 'SUCCESSFUL_GET': 74 | if (!action.payload) { 75 | return state; 76 | } 77 | 78 | return { 79 | ...state, 80 | [action.payload.institution_id]: action.payload, 81 | }; 82 | default: 83 | console.warn('unknown action'); 84 | return state; 85 | } 86 | } 87 | 88 | /** 89 | * @desc A convenience hook to provide access to the Institutions context state in components. 90 | */ 91 | export default function useInstitutions() { 92 | const context = useContext(InstitutionsContext); 93 | 94 | if (!context) { 95 | throw new Error( 96 | `useInstitutions must be used within an InstitutionsProvider` 97 | ); 98 | } 99 | 100 | return context; 101 | } 102 | 103 | /** 104 | * @desc Prepends base64 encoded logo src for use in image tags 105 | */ 106 | function formatLogoSrc(src: string) { 107 | return src && `data:image/jpeg;base64,${src}`; 108 | } 109 | -------------------------------------------------------------------------------- /client/src/services/items.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useContext, 4 | useMemo, 5 | useRef, 6 | useReducer, 7 | useCallback, 8 | Dispatch, 9 | } from 'react'; 10 | import groupBy from 'lodash/groupBy'; 11 | import keyBy from 'lodash/keyBy'; 12 | import omit from 'lodash/omit'; 13 | import omitBy from 'lodash/omitBy'; 14 | 15 | import { ItemType } from '../components/types'; 16 | 17 | import { 18 | getItemsByUser as apiGetItemsByUser, 19 | getItemById as apiGetItemById, 20 | deleteItemById as apiDeleteItemById, 21 | } from './api.tsx'; 22 | 23 | interface ItemsState { 24 | [itemId: number]: ItemType; 25 | } 26 | 27 | const initialState: ItemsState = {}; 28 | type ItemsAction = 29 | | { 30 | type: 'SUCCESSFUL_REQUEST'; 31 | payload: ItemType[]; 32 | } 33 | | { type: 'SUCCESSFUL_DELETE'; payload: number } 34 | | { type: 'DELETE_BY_USER'; payload: number }; 35 | 36 | interface ItemsContextShape extends ItemsState { 37 | dispatch: Dispatch; 38 | deleteItemById: (id: number, userId: number) => void; 39 | getItemsByUser: (userId: number, refresh: boolean) => void; 40 | getItemById: (id: number, refresh: boolean) => void; 41 | itemsById: { [itemId: number]: ItemType[] }; 42 | itemsByUser: { [userId: number]: ItemType[] }; 43 | deleteItemsByUserId: (userId: number) => void; 44 | } 45 | const ItemsContext = createContext( 46 | initialState as ItemsContextShape 47 | ); 48 | 49 | /** 50 | * @desc Maintains the Items context state and provides functions to update that state. 51 | */ 52 | export function ItemsProvider(props: any) { 53 | const [itemsById, dispatch] = useReducer(reducer, {}); 54 | const hasRequested = useRef<{ byId: { [id: number]: boolean } }>({ 55 | byId: {}, 56 | }); 57 | 58 | /** 59 | * @desc Requests details for a single Item. 60 | * The api request will be bypassed if the data has already been fetched. 61 | * A 'refresh' parameter can force a request for new data even if local state exists. 62 | */ 63 | const getItemById = useCallback(async (id, refresh) => { 64 | if (!hasRequested.current.byId[id] || refresh) { 65 | hasRequested.current.byId[id] = true; 66 | const { data: payload } = await apiGetItemById(id); 67 | dispatch({ type: 'SUCCESSFUL_REQUEST', payload: payload }); 68 | } 69 | }, []); 70 | 71 | /** 72 | * @desc Requests all Items that belong to an individual User. 73 | */ 74 | const getItemsByUser = useCallback(async userId => { 75 | const { data: payload } = await apiGetItemsByUser(userId); 76 | dispatch({ type: 'SUCCESSFUL_REQUEST', payload: payload }); 77 | }, []); 78 | 79 | /** 80 | * @desc Will deletes Item by itemId. 81 | */ 82 | const deleteItemById = useCallback( 83 | async (id, userId) => { 84 | await apiDeleteItemById(id); 85 | dispatch({ type: 'SUCCESSFUL_DELETE', payload: id }); 86 | // Update items list after deletion. 87 | await getItemsByUser(userId); 88 | 89 | delete hasRequested.current.byId[id]; 90 | }, 91 | [getItemsByUser] 92 | ); 93 | 94 | /** 95 | * @desc Will delete all items that belong to an individual User. 96 | * There is no api request as apiDeleteItemById in items delete all related transactions 97 | */ 98 | const deleteItemsByUserId = useCallback(userId => { 99 | dispatch({ type: 'DELETE_BY_USER', payload: userId }); 100 | }, []); 101 | 102 | /** 103 | * @desc Builds a more accessible state shape from the Items data. useMemo will prevent 104 | * these from being rebuilt on every render unless itemsById is updated in the reducer. 105 | */ 106 | const value = useMemo(() => { 107 | const allItems = Object.values(itemsById); 108 | 109 | return { 110 | allItems, 111 | itemsById, 112 | itemsByUser: groupBy(allItems, 'user_id'), 113 | getItemById, 114 | getItemsByUser, 115 | deleteItemById, 116 | deleteItemsByUserId, 117 | }; 118 | }, [ 119 | itemsById, 120 | getItemById, 121 | getItemsByUser, 122 | deleteItemById, 123 | deleteItemsByUserId, 124 | ]); 125 | 126 | return ; 127 | } 128 | 129 | /** 130 | * @desc Handles updates to the Items state as dictated by dispatched actions. 131 | */ 132 | function reducer(state: ItemsState, action: ItemsAction) { 133 | switch (action.type) { 134 | case 'SUCCESSFUL_REQUEST': 135 | if (!action.payload.length) { 136 | return state; 137 | } 138 | 139 | return { ...state, ...keyBy(action.payload, 'id') }; 140 | case 'SUCCESSFUL_DELETE': 141 | return omit(state, [action.payload]); 142 | case 'DELETE_BY_USER': 143 | return omitBy(state, items => items.user_id === action.payload); 144 | default: 145 | console.warn('unknown action'); 146 | return state; 147 | } 148 | } 149 | 150 | /** 151 | * @desc A convenience hook to provide access to the Items context state in components. 152 | */ 153 | export default function useItems() { 154 | const context = useContext(ItemsContext); 155 | 156 | if (!context) { 157 | throw new Error(`useItems must be used within an ItemsProvider`); 158 | } 159 | 160 | return context; 161 | } 162 | -------------------------------------------------------------------------------- /client/src/services/link.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useCallback, 3 | useContext, 4 | useMemo, 5 | useReducer, 6 | Dispatch, 7 | createContext, 8 | } from 'react'; 9 | 10 | import { getLinkToken } from './api.tsx'; 11 | 12 | import { PlaidLinkError } from 'react-plaid-link'; 13 | 14 | interface LinkToken { 15 | [key: string]: string; 16 | } 17 | 18 | interface LinkState { 19 | byUser: LinkToken; 20 | byItem: LinkToken; 21 | error: PlaidLinkError; 22 | } 23 | 24 | const initialState = { 25 | byUser: {}, // normal case 26 | byItem: {}, // update mode 27 | error: {}, 28 | }; 29 | type LinkAction = 30 | | { 31 | type: 'LINK_TOKEN_CREATED'; 32 | id: number; 33 | token: string; 34 | } 35 | | { type: 'LINK_TOKEN_UPDATE_MODE_CREATED'; id: number; token: string } 36 | | { type: 'LINK_TOKEN_ERROR'; error: PlaidLinkError } 37 | | { type: 'DELETE_USER_LINK_TOKEN'; id: number } 38 | | { type: 'DELETE_ITEM_LINK_TOKEN'; id: number }; 39 | 40 | interface LinkContextShape extends LinkState { 41 | dispatch: Dispatch; 42 | generateLinkToken: ( 43 | userId: number, 44 | itemId: number | null | undefined 45 | ) => void; 46 | deleteLinkToken: (userId: number | null, itemId: number | null) => void; 47 | linkTokens: LinkState; 48 | } 49 | const LinkContext = createContext( 50 | initialState as LinkContextShape 51 | ); 52 | 53 | /** 54 | * @desc Maintains the Link context state and fetches link tokens to update that state. 55 | */ 56 | export function LinkProvider(props: any) { 57 | const [linkTokens, dispatch] = useReducer(reducer, initialState); 58 | 59 | /** 60 | * @desc Creates a new link token for a given User or Item. 61 | */ 62 | 63 | const generateLinkToken = useCallback(async (userId, itemId) => { 64 | // if itemId is not null, update mode is triggered 65 | const linkTokenResponse = await getLinkToken(userId, itemId); 66 | if (linkTokenResponse.data.link_token) { 67 | const token = await linkTokenResponse.data.link_token; 68 | console.log('success', linkTokenResponse.data); 69 | 70 | if (itemId != null) { 71 | dispatch({ 72 | type: 'LINK_TOKEN_UPDATE_MODE_CREATED', 73 | id: itemId, 74 | token: token, 75 | }); 76 | } else { 77 | dispatch({ type: 'LINK_TOKEN_CREATED', id: userId, token: token }); 78 | } 79 | } else { 80 | dispatch({ type: 'LINK_TOKEN_ERROR', error: linkTokenResponse.data }); 81 | console.log('error', linkTokenResponse.data); 82 | } 83 | }, []); 84 | 85 | const deleteLinkToken = useCallback(async (userId, itemId) => { 86 | if (userId != null) { 87 | dispatch({ 88 | type: 'DELETE_USER_LINK_TOKEN', 89 | id: userId, 90 | }); 91 | } else { 92 | dispatch({ 93 | type: 'DELETE_ITEM_LINK_TOKEN', 94 | id: itemId, 95 | }); 96 | } 97 | }, []); 98 | 99 | const value = useMemo( 100 | () => ({ 101 | generateLinkToken, 102 | deleteLinkToken, 103 | linkTokens, 104 | }), 105 | [linkTokens, generateLinkToken, deleteLinkToken] 106 | ); 107 | 108 | return ; 109 | } 110 | 111 | /** 112 | * @desc Handles updates to the LinkTokens state as dictated by dispatched actions. 113 | */ 114 | function reducer(state: any, action: LinkAction) { 115 | switch (action.type) { 116 | case 'LINK_TOKEN_CREATED': 117 | return { 118 | ...state, 119 | byUser: { 120 | [action.id]: action.token, 121 | }, 122 | error: {}, 123 | }; 124 | 125 | case 'LINK_TOKEN_UPDATE_MODE_CREATED': 126 | return { 127 | ...state, 128 | error: {}, 129 | byItem: { 130 | ...state.byItem, 131 | [action.id]: action.token, 132 | }, 133 | }; 134 | case 'DELETE_USER_LINK_TOKEN': 135 | return { 136 | ...state, 137 | byUser: { 138 | [action.id]: '', 139 | }, 140 | }; 141 | case 'DELETE_ITEM_LINK_TOKEN': 142 | return { 143 | ...state, 144 | byItem: { 145 | ...state.byItem, 146 | [action.id]: '', 147 | }, 148 | }; 149 | case 'LINK_TOKEN_ERROR': 150 | return { 151 | ...state, 152 | error: action.error, 153 | }; 154 | default: 155 | console.warn('unknown action'); 156 | return state; 157 | } 158 | } 159 | 160 | /** 161 | * @desc A convenience hook to provide access to the Link context state in components. 162 | */ 163 | export default function useLink() { 164 | const context = useContext(LinkContext); 165 | if (!context) { 166 | throw new Error(`useLink must be used within a LinkProvider`); 167 | } 168 | 169 | return context; 170 | } 171 | -------------------------------------------------------------------------------- /client/src/services/transactions.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useContext, 4 | useMemo, 5 | useRef, 6 | useReducer, 7 | useCallback, 8 | Dispatch, 9 | } from 'react'; 10 | import groupBy from 'lodash/groupBy'; 11 | import keyBy from 'lodash/keyBy'; 12 | import omitBy from 'lodash/omitBy'; 13 | 14 | import { TransactionType } from '../components/types'; 15 | 16 | import { 17 | getTransactionsByAccount as apiGetTransactionsByAccount, 18 | getTransactionsByItem as apiGetTransactionsByItem, 19 | getTransactionsByUser as apiGetTransactionsByUser, 20 | } from './api.tsx'; 21 | import { Dictionary } from 'lodash'; 22 | 23 | interface TransactionsState { 24 | [transactionId: number]: TransactionType; 25 | } 26 | 27 | const initialState = {}; 28 | type TransactionsAction = 29 | | { 30 | type: 'SUCCESSFUL_GET'; 31 | payload: TransactionType[]; 32 | } 33 | | { type: 'DELETE_BY_ITEM'; payload: number } 34 | | { type: 'DELETE_BY_USER'; payload: number }; 35 | 36 | interface TransactionsContextShape extends TransactionsState { 37 | dispatch: Dispatch; 38 | transactionsByAccount: Dictionary; 39 | getTransactionsByAccount: (accountId: number, refresh?: boolean) => void; 40 | deleteTransactionsByItemId: (itemId: number) => void; 41 | deleteTransactionsByUserId: (userId: number) => void; 42 | transactionsByUser: Dictionary; 43 | getTransactionsByUser: (userId: number) => void; 44 | transactionsByItem: Dictionary; 45 | } 46 | const TransactionsContext = createContext( 47 | initialState as TransactionsContextShape 48 | ); 49 | 50 | /** 51 | * @desc Maintains the Transactions context state and provides functions to update that state. 52 | * 53 | * The transactions requests below are made from the database only. Calls to the Plaid transactions/get endpoint are only 54 | * made following receipt of transactions webhooks such as 'DEFAULT_UPDATE' or 'INITIAL_UPDATE'. 55 | */ 56 | export function TransactionsProvider(props: any) { 57 | const [transactionsById, dispatch] = useReducer(reducer, initialState); 58 | 59 | const hasRequested = useRef<{ 60 | byAccount: { [accountId: number]: boolean }; 61 | }>({ 62 | byAccount: {}, 63 | }); 64 | 65 | /** 66 | * @desc Requests all Transactions that belong to an individual Account. 67 | * The api request will be bypassed if the data has already been fetched. 68 | * A 'refresh' parameter can force a request for new data even if local state exists. 69 | */ 70 | const getTransactionsByAccount = useCallback(async (accountId, refresh) => { 71 | if (!hasRequested.current.byAccount[accountId] || refresh) { 72 | hasRequested.current.byAccount[accountId] = true; 73 | const { data: payload } = await apiGetTransactionsByAccount(accountId); 74 | dispatch({ type: 'SUCCESSFUL_GET', payload: payload }); 75 | } 76 | }, []); 77 | 78 | /** 79 | * @desc Requests all Transactions that belong to an individual Item. 80 | */ 81 | const getTransactionsByItem = useCallback(async itemId => { 82 | const { data: payload } = await apiGetTransactionsByItem(itemId); 83 | dispatch({ type: 'SUCCESSFUL_GET', payload: payload }); 84 | }, []); 85 | 86 | /** 87 | * @desc Requests all Transactions that belong to an individual User. 88 | */ 89 | const getTransactionsByUser = useCallback(async userId => { 90 | const { data: payload } = await apiGetTransactionsByUser(userId); 91 | dispatch({ type: 'SUCCESSFUL_GET', payload: payload }); 92 | }, []); 93 | 94 | /** 95 | * @desc Will Delete all transactions that belong to an individual Item. 96 | * There is no api request as apiDeleteItemById in items delete all related transactions 97 | */ 98 | const deleteTransactionsByItemId = useCallback(itemId => { 99 | dispatch({ type: 'DELETE_BY_ITEM', payload: itemId }); 100 | }, []); 101 | 102 | /** 103 | * @desc Will Delete all transactions that belong to an individual User. 104 | * There is no api request as apiDeleteItemById in items delete all related transactions 105 | */ 106 | const deleteTransactionsByUserId = useCallback(userId => { 107 | dispatch({ type: 'DELETE_BY_USER', payload: userId }); 108 | }, []); 109 | 110 | /** 111 | * @desc Builds a more accessible state shape from the Transactions data. useMemo will prevent 112 | * these from being rebuilt on every render unless transactionsById is updated in the reducer. 113 | */ 114 | const value = useMemo(() => { 115 | const allTransactions = Object.values(transactionsById); 116 | 117 | return { 118 | dispatch, 119 | allTransactions, 120 | transactionsById, 121 | transactionsByAccount: groupBy(allTransactions, 'account_id'), 122 | transactionsByItem: groupBy(allTransactions, 'item_id'), 123 | transactionsByUser: groupBy(allTransactions, 'user_id'), 124 | getTransactionsByAccount, 125 | getTransactionsByItem, 126 | getTransactionsByUser, 127 | deleteTransactionsByItemId, 128 | deleteTransactionsByUserId, 129 | }; 130 | }, [ 131 | dispatch, 132 | transactionsById, 133 | getTransactionsByAccount, 134 | getTransactionsByItem, 135 | getTransactionsByUser, 136 | deleteTransactionsByItemId, 137 | deleteTransactionsByUserId, 138 | ]); 139 | 140 | return ; 141 | } 142 | 143 | /** 144 | * @desc Handles updates to the Transactions state as dictated by dispatched actions. 145 | */ 146 | function reducer(state: TransactionsState, action: TransactionsAction | any) { 147 | switch (action.type) { 148 | case 'SUCCESSFUL_GET': 149 | if (!action.payload.length) { 150 | return state; 151 | } 152 | return { 153 | ...state, 154 | ...keyBy(action.payload, 'id'), 155 | }; 156 | case 'DELETE_BY_ITEM': 157 | return omitBy( 158 | state, 159 | transaction => transaction.item_id === action.payload 160 | ); 161 | case 'DELETE_BY_USER': 162 | return omitBy( 163 | state, 164 | transaction => transaction.user_id === action.payload 165 | ); 166 | default: 167 | console.warn('unknown action: ', action.type, action.payload); 168 | return state; 169 | } 170 | } 171 | 172 | /** 173 | * @desc A convenience hook to provide access to the Transactions context state in components. 174 | */ 175 | export default function useTransactions() { 176 | const context = useContext(TransactionsContext); 177 | 178 | if (!context) { 179 | throw new Error( 180 | `useTransactions must be used within a TransactionsProvider` 181 | ); 182 | } 183 | 184 | return context; 185 | } 186 | -------------------------------------------------------------------------------- /client/src/services/users.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useContext, 4 | useMemo, 5 | useRef, 6 | useReducer, 7 | useCallback, 8 | Dispatch, 9 | } from 'react'; 10 | import keyBy from 'lodash/keyBy'; 11 | import omit from 'lodash/omit'; 12 | import { toast } from 'react-toastify'; 13 | 14 | import { UserType } from '../components/types'; 15 | import useItems from './items.tsx'; 16 | import useAccounts from './accounts.tsx'; 17 | import useTransactions from './transactions.tsx'; 18 | import { 19 | getUsers as apiGetUsers, 20 | getUserById as apiGetUserById, 21 | addNewUser as apiAddNewUser, 22 | deleteUserById as apiDeleteUserById, 23 | } from './api.tsx'; 24 | 25 | interface UsersState { 26 | [key: string]: UserType | any; 27 | } 28 | 29 | const initialState = {}; 30 | type UsersAction = 31 | | { 32 | type: 'SUCCESSFUL_GET'; 33 | payload: UserType; 34 | } 35 | | { type: 'SUCCESSFUL_DELETE'; payload: number }; 36 | 37 | interface UsersContextShape extends UsersState { 38 | dispatch: Dispatch; 39 | } 40 | const UsersContext = createContext( 41 | initialState as UsersContextShape 42 | ); 43 | 44 | /** 45 | * @desc Maintains the Users context state and provides functions to update that state. 46 | */ 47 | export function UsersProvider(props: any) { 48 | const [usersById, dispatch] = useReducer(reducer, {}); 49 | const { deleteAccountsByUserId } = useAccounts(); 50 | const { deleteItemsByUserId } = useItems(); 51 | const { deleteTransactionsByUserId } = useTransactions(); 52 | 53 | const hasRequested = useRef<{ 54 | all: Boolean; 55 | byId: { [id: number]: boolean }; 56 | }>({ 57 | all: false, 58 | byId: {}, 59 | }); 60 | 61 | /** 62 | * @desc Creates a new user 63 | */ 64 | const addNewUser = useCallback(async username => { 65 | try { 66 | const { data: payload } = await apiAddNewUser(username); 67 | dispatch({ type: 'SUCCESSFUL_GET', payload: payload }); 68 | } catch (err) { 69 | const { response } = err; 70 | if (response && response.status === 409) { 71 | toast.error(`Username ${username} already exists`); 72 | } else { 73 | toast.error('Error adding new user'); 74 | } 75 | } 76 | }, []); 77 | 78 | /** 79 | * @desc Requests all Users. 80 | * The api request will be bypassed if the data has already been fetched. 81 | * A 'refresh' parameter can force a request for new data even if local state exists. 82 | */ 83 | const getUsers = useCallback(async refresh => { 84 | if (!hasRequested.current.all || refresh) { 85 | hasRequested.current.all = true; 86 | const { data: payload } = await apiGetUsers(); 87 | dispatch({ type: 'SUCCESSFUL_GET', payload: payload }); 88 | } 89 | }, []); 90 | 91 | /** 92 | * @desc Requests details for a single User. 93 | * The api request will be bypassed if the data has already been fetched. 94 | * A 'refresh' parameter can force a request for new data even if local state exists. 95 | */ 96 | const getUserById = useCallback(async (id, refresh) => { 97 | if (!hasRequested.current.byId[id] || refresh) { 98 | hasRequested.current.byId[id] = true; 99 | const { data: payload } = await apiGetUserById(id); 100 | dispatch({ type: 'SUCCESSFUL_GET', payload: payload }); 101 | } 102 | }, []); 103 | 104 | /** 105 | * @desc Will delete User by userId. 106 | */ 107 | const deleteUserById = useCallback( 108 | async id => { 109 | await apiDeleteUserById(id); // this will delete all items associated with user 110 | deleteItemsByUserId(id); 111 | deleteAccountsByUserId(id); 112 | deleteTransactionsByUserId(id); 113 | dispatch({ type: 'SUCCESSFUL_DELETE', payload: id }); 114 | delete hasRequested.current.byId[id]; 115 | }, 116 | [deleteItemsByUserId, deleteAccountsByUserId, deleteTransactionsByUserId] 117 | ); 118 | 119 | /** 120 | * @desc Builds a more accessible state shape from the Users data. useMemo will prevent 121 | * these from being rebuilt on every render unless usersById is updated in the reducer. 122 | */ 123 | const value = useMemo(() => { 124 | const allUsers = Object.values(usersById); 125 | return { 126 | allUsers, 127 | usersById, 128 | getUsers, 129 | getUserById, 130 | getUsersById: getUserById, 131 | addNewUser, 132 | deleteUserById, 133 | }; 134 | }, [usersById, getUsers, getUserById, addNewUser, deleteUserById]); 135 | 136 | return ; 137 | } 138 | 139 | /** 140 | * @desc Handles updates to the Users state as dictated by dispatched actions. 141 | */ 142 | function reducer(state: UsersState, action: UsersAction | any) { 143 | switch (action.type) { 144 | case 'SUCCESSFUL_GET': 145 | if (!action.payload.length) { 146 | return state; 147 | } 148 | return { 149 | ...state, 150 | ...keyBy(action.payload, 'id'), 151 | }; 152 | case 'SUCCESSFUL_DELETE': 153 | return omit(state, [action.payload]); 154 | default: 155 | console.warn('unknown action: ', action.type, action.payload); 156 | return state; 157 | } 158 | } 159 | 160 | /** 161 | * @desc A convenience hook to provide access to the Users context state in components. 162 | */ 163 | export default function useUsers() { 164 | const context = useContext(UsersContext); 165 | 166 | if (!context) { 167 | throw new Error(`useUsers must be used within a UsersProvider`); 168 | } 169 | 170 | return context; 171 | } 172 | -------------------------------------------------------------------------------- /client/src/util/index.tsx: -------------------------------------------------------------------------------- 1 | import { distanceInWords, parse } from 'date-fns'; 2 | import { 3 | PlaidLinkOnSuccessMetadata, 4 | PlaidLinkOnExitMetadata, 5 | PlaidLinkStableEvent, 6 | PlaidLinkOnEventMetadata, 7 | PlaidLinkError, 8 | } from 'react-plaid-link'; 9 | 10 | import { postLinkEvent as apiPostLinkEvent } from '../services/api.tsx'; 11 | 12 | /** 13 | * @desc small helper for pluralizing words for display given a number of items 14 | */ 15 | export function pluralize(noun: string, count: number) { 16 | return count === 1 ? noun : `${noun}s`; 17 | } 18 | 19 | /** 20 | * @desc converts number values into $ currency strings 21 | */ 22 | export function currencyFilter(value: number) { 23 | if (typeof value !== 'number') { 24 | return '-'; 25 | } 26 | 27 | const isNegative = value < 0; 28 | const displayValue = value < 0 ? -value : value; 29 | return `${isNegative ? '-' : ''}$${displayValue 30 | .toFixed(2) 31 | .replace(/(\d)(?=(\d{3})+(\.|$))/g, '$1,')}`; 32 | } 33 | 34 | const months = [ 35 | null, 36 | 'January', 37 | 'February', 38 | 'March', 39 | 'April', 40 | 'May', 41 | 'June', 42 | 'July', 43 | 'August', 44 | 'September', 45 | 'October', 46 | 'November', 47 | 'December', 48 | ]; 49 | 50 | /** 51 | * @desc Returns formatted date. 52 | */ 53 | export function formatDate(timestamp: string) { 54 | if (timestamp) { 55 | // slice will return the first 10 char(date)of timestamp 56 | // coming in as: 2019-05-07T15:41:30.520Z 57 | const [y, m, d] = timestamp.slice(0, 10).split('-'); 58 | return `${months[+m]} ${d}, ${y}`; 59 | } 60 | 61 | return ''; 62 | } 63 | 64 | /** 65 | * @desc Checks the difference between the current time and a provided time 66 | */ 67 | export function diffBetweenCurrentTime(timestamp: string) { 68 | return distanceInWords(new Date(), parse(timestamp), { 69 | addSuffix: true, 70 | includeSeconds: true, 71 | }).replace(/^(about|less than)\s/i, ''); 72 | } 73 | 74 | export const logEvent = ( 75 | eventName: PlaidLinkStableEvent | string, 76 | metadata: 77 | | PlaidLinkOnEventMetadata 78 | | PlaidLinkOnSuccessMetadata 79 | | PlaidLinkOnExitMetadata, 80 | error?: PlaidLinkError | null 81 | ) => { 82 | console.log(`Link Event: ${eventName}`, metadata, error); 83 | }; 84 | 85 | export const logSuccess = async ( 86 | { institution, accounts, link_session_id }: PlaidLinkOnSuccessMetadata, 87 | userId: number 88 | ) => { 89 | logEvent('onSuccess', { 90 | institution, 91 | accounts, 92 | link_session_id, 93 | }); 94 | await apiPostLinkEvent({ 95 | userId, 96 | link_session_id, 97 | type: 'success', 98 | }); 99 | }; 100 | 101 | export const logExit = async ( 102 | error: PlaidLinkError | null, 103 | { institution, status, link_session_id, request_id }: PlaidLinkOnExitMetadata, 104 | userId: number 105 | ) => { 106 | logEvent( 107 | 'onExit', 108 | { 109 | institution, 110 | status, 111 | link_session_id, 112 | request_id, 113 | }, 114 | error 115 | ); 116 | 117 | const eventError = error || {}; 118 | await apiPostLinkEvent({ 119 | userId, 120 | link_session_id, 121 | request_id, 122 | type: 'exit', 123 | ...eventError, 124 | status, 125 | }); 126 | }; 127 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "allowImportingTsExtensions": true, 11 | "skipLibCheck": true, 12 | "baseUrl": "./", 13 | "esModuleInterop": true, 14 | "allowSyntheticDefaultImports": true, 15 | "strict": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "incremental": true, 23 | "jsx": "react-jsx", 24 | "noFallthroughCasesInSwitch": true 25 | }, 26 | "include": [ 27 | "src", 28 | "index.js" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /database/init/create.sql: -------------------------------------------------------------------------------- 1 | -- This trigger updates the value in the updated_at column. It is used in the tables below to log 2 | -- when a row was last updated. 3 | 4 | CREATE OR REPLACE FUNCTION trigger_set_timestamp() 5 | RETURNS TRIGGER AS $$ 6 | BEGIN 7 | NEW.updated_at = NOW(); 8 | RETURN NEW; 9 | END; 10 | $$ LANGUAGE plpgsql; 11 | 12 | 13 | -- USERS 14 | -- This table is used to store the users of our application. The view returns the same data as the 15 | -- table, we're just creating it to follow the pattern used in other tables. 16 | 17 | CREATE TABLE users_table 18 | ( 19 | id SERIAL PRIMARY KEY, 20 | username text UNIQUE NOT NULL, 21 | created_at timestamptz default now(), 22 | updated_at timestamptz default now() 23 | ); 24 | 25 | CREATE TRIGGER users_updated_at_timestamp 26 | BEFORE UPDATE ON users_table 27 | FOR EACH ROW 28 | EXECUTE PROCEDURE trigger_set_timestamp(); 29 | 30 | CREATE VIEW users 31 | AS 32 | SELECT 33 | id, 34 | username, 35 | created_at, 36 | updated_at 37 | FROM 38 | users_table; 39 | 40 | 41 | -- ITEMS 42 | -- This table is used to store the items associated with each user. The view returns the same data 43 | -- as the table, we're just using both to maintain consistency with our other tables. For more info 44 | -- on the Plaid Item schema, see the docs page: https://plaid.com/docs/#item-schema 45 | 46 | CREATE TABLE items_table 47 | ( 48 | id SERIAL PRIMARY KEY, 49 | user_id integer REFERENCES users_table(id) ON DELETE CASCADE, 50 | plaid_access_token text UNIQUE NOT NULL, 51 | plaid_item_id text UNIQUE NOT NULL, 52 | plaid_institution_id text NOT NULL, 53 | status text NOT NULL, 54 | created_at timestamptz default now(), 55 | updated_at timestamptz default now(), 56 | transactions_cursor text 57 | ); 58 | 59 | CREATE TRIGGER items_updated_at_timestamp 60 | BEFORE UPDATE ON items_table 61 | FOR EACH ROW 62 | EXECUTE PROCEDURE trigger_set_timestamp(); 63 | 64 | CREATE VIEW items 65 | AS 66 | SELECT 67 | id, 68 | plaid_item_id, 69 | user_id, 70 | plaid_access_token, 71 | plaid_institution_id, 72 | status, 73 | created_at, 74 | updated_at, 75 | transactions_cursor 76 | FROM 77 | items_table; 78 | 79 | 80 | -- -- ASSETS 81 | -- -- This table is used to store the assets associated with each user. The view returns the same data 82 | -- -- as the table, we're just using both to maintain consistency with our other tables. 83 | 84 | CREATE TABLE assets_table 85 | ( 86 | id SERIAL PRIMARY KEY, 87 | user_id integer REFERENCES users_table(id) ON DELETE CASCADE, 88 | value numeric(28,2), 89 | description text, 90 | created_at timestamptz default now(), 91 | updated_at timestamptz default now() 92 | ); 93 | 94 | CREATE TRIGGER assets_updated_at_timestamp 95 | BEFORE UPDATE ON assets_table 96 | FOR EACH ROW 97 | EXECUTE PROCEDURE trigger_set_timestamp(); 98 | 99 | CREATE VIEW assets 100 | AS 101 | SELECT 102 | id, 103 | user_id, 104 | value, 105 | description, 106 | created_at, 107 | updated_at 108 | FROM 109 | assets_table; 110 | 111 | 112 | 113 | 114 | -- ACCOUNTS 115 | -- This table is used to store the accounts associated with each item. The view returns all the 116 | -- data from the accounts table and some data from the items view. For more info on the Plaid 117 | -- Accounts schema, see the docs page: https://plaid.com/docs/#account-schema 118 | 119 | CREATE TABLE accounts_table 120 | ( 121 | id SERIAL PRIMARY KEY, 122 | item_id integer REFERENCES items_table(id) ON DELETE CASCADE, 123 | plaid_account_id text UNIQUE NOT NULL, 124 | name text NOT NULL, 125 | mask text NOT NULL, 126 | official_name text, 127 | current_balance numeric(28,10), 128 | available_balance numeric(28,10), 129 | iso_currency_code text, 130 | unofficial_currency_code text, 131 | type text NOT NULL, 132 | subtype text NOT NULL, 133 | created_at timestamptz default now(), 134 | updated_at timestamptz default now() 135 | ); 136 | 137 | CREATE TRIGGER accounts_updated_at_timestamp 138 | BEFORE UPDATE ON accounts_table 139 | FOR EACH ROW 140 | EXECUTE PROCEDURE trigger_set_timestamp(); 141 | 142 | CREATE VIEW accounts 143 | AS 144 | SELECT 145 | a.id, 146 | a.plaid_account_id, 147 | a.item_id, 148 | i.plaid_item_id, 149 | i.user_id, 150 | a.name, 151 | a.mask, 152 | a.official_name, 153 | a.current_balance, 154 | a.available_balance, 155 | a.iso_currency_code, 156 | a.unofficial_currency_code, 157 | a.type, 158 | a.subtype, 159 | a.created_at, 160 | a.updated_at 161 | FROM 162 | accounts_table a 163 | LEFT JOIN items i ON i.id = a.item_id; 164 | 165 | 166 | -- TRANSACTIONS 167 | -- This table is used to store the transactions associated with each account. The view returns all 168 | -- the data from the transactions table and some data from the accounts view. For more info on the 169 | -- Plaid Transactions schema, see the docs page: https://plaid.com/docs/#transaction-schema 170 | 171 | CREATE TABLE transactions_table 172 | ( 173 | id SERIAL PRIMARY KEY, 174 | account_id integer REFERENCES accounts_table(id) ON DELETE CASCADE, 175 | plaid_transaction_id text UNIQUE NOT NULL, 176 | plaid_category_id text, 177 | category text, 178 | type text NOT NULL, 179 | name text NOT NULL, 180 | amount numeric(28,10) NOT NULL, 181 | iso_currency_code text, 182 | unofficial_currency_code text, 183 | date date NOT NULL, 184 | pending boolean NOT NULL, 185 | account_owner text, 186 | created_at timestamptz default now(), 187 | updated_at timestamptz default now() 188 | ); 189 | 190 | CREATE TRIGGER transactions_updated_at_timestamp 191 | BEFORE UPDATE ON transactions_table 192 | FOR EACH ROW 193 | EXECUTE PROCEDURE trigger_set_timestamp(); 194 | 195 | CREATE VIEW transactions 196 | AS 197 | SELECT 198 | t.id, 199 | t.plaid_transaction_id, 200 | t.account_id, 201 | a.plaid_account_id, 202 | a.item_id, 203 | a.plaid_item_id, 204 | a.user_id, 205 | t.category, 206 | t.type, 207 | t.name, 208 | t.amount, 209 | t.iso_currency_code, 210 | t.unofficial_currency_code, 211 | t.date, 212 | t.pending, 213 | t.account_owner, 214 | t.created_at, 215 | t.updated_at 216 | FROM 217 | transactions_table t 218 | LEFT JOIN accounts a ON t.account_id = a.id; 219 | 220 | 221 | -- The link_events_table is used to log responses from the Plaid API for client requests to the 222 | -- Plaid Link client. This information is useful for troubleshooting. 223 | 224 | CREATE TABLE link_events_table 225 | ( 226 | id SERIAL PRIMARY KEY, 227 | type text NOT NULL, 228 | user_id integer, 229 | link_session_id text, 230 | request_id text UNIQUE, 231 | error_type text, 232 | error_code text, 233 | status text, 234 | created_at timestamptz default now() 235 | ); 236 | 237 | 238 | -- The plaid_api_events_table is used to log responses from the Plaid API for server requests to 239 | -- the Plaid client. This information is useful for troubleshooting. 240 | 241 | CREATE TABLE plaid_api_events_table 242 | ( 243 | id SERIAL PRIMARY KEY, 244 | item_id integer, 245 | user_id integer, 246 | plaid_method text NOT NULL, 247 | arguments text, 248 | request_id text UNIQUE, 249 | error_type text, 250 | error_code text, 251 | created_at timestamptz default now() 252 | ); 253 | -------------------------------------------------------------------------------- /docker-compose.debug.yml: -------------------------------------------------------------------------------- 1 | # this file overrides docker-compose.yml. the should be run together, e.g. 2 | # docker-compose -f docker-compose.yml -f docker-compose.debug.yml up 3 | 4 | version: "3.4" 5 | 6 | volumes: 7 | client_node_modules: 8 | server_node_modules: 9 | 10 | services: 11 | server: 12 | build: ./server 13 | image: plaidinc/pattern-server:debug 14 | command: ["npm", "run", "debug"] 15 | volumes: 16 | - ./server:/opt/server 17 | - server_node_modules:/opt/server/node_modules 18 | ports: 19 | - 5001:5001 20 | - 9229:9229 21 | 22 | ngrok: 23 | build: ./ngrok 24 | image: plaidinc/pattern-ngrok:debug 25 | 26 | client: 27 | build: ./client 28 | image: plaidinc/pattern-client:debug 29 | volumes: 30 | - ./client:/opt/client 31 | - client_node_modules:/opt/client/node_modules 32 | ports: 33 | - 3001:3001 34 | - 35729:35729 35 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.4" 2 | 3 | x-common-variables: &common-variables 4 | POSTGRES_USER: postgres 5 | POSTGRES_PASSWORD: password 6 | 7 | volumes: 8 | pg_sandbox_data: 9 | pg_production_data: 10 | 11 | services: 12 | db: 13 | image: postgres:11.2 14 | volumes: 15 | - ./database/init:/docker-entrypoint-initdb.d 16 | - "pg_${PLAID_ENV}_data:/var/lib/postgresql/data" 17 | ports: 18 | - 5432:5432 19 | environment: *common-variables 20 | 21 | server: 22 | build: ./server 23 | image: plaidinc/pattern-server:1.0.7 24 | ports: 25 | - 5001:5001 26 | environment: 27 | <<: *common-variables 28 | PLAID_CLIENT_ID: 29 | PLAID_SECRET_SANDBOX: 30 | PLAID_SECRET_PRODUCTION: 31 | PLAID_SANDBOX_REDIRECT_URI: 32 | PLAID_PRODUCTION_REDIRECT_URI: 33 | PLAID_ENV: 34 | PORT: 5001 35 | DB_PORT: 5432 36 | DB_HOST_NAME: db 37 | depends_on: 38 | - db 39 | 40 | ngrok: 41 | build: ./ngrok 42 | image: plaidinc/pattern-ngrok:1.0.7 43 | command: ["ngrok", "http", "server:5001"] 44 | ports: 45 | - 4040:4040 46 | depends_on: 47 | - server 48 | 49 | client: 50 | build: ./client 51 | image: plaidinc/pattern-client:1.0.7 52 | ports: 53 | - 3001:3001 54 | environment: 55 | REACT_APP_PLAID_ENV: ${PLAID_ENV} 56 | REACT_APP_SERVER_PORT: 5001 57 | depends_on: 58 | - server 59 | -------------------------------------------------------------------------------- /docs/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plaid/pattern/e7a4cd4eba9e65edc482d5c5355f07e0fb3ca694/docs/.DS_Store -------------------------------------------------------------------------------- /docs/pattern_screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plaid/pattern/e7a4cd4eba9e65edc482d5c5355f07e0fb3ca694/docs/pattern_screenshot.jpg -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | View the logs with the following Docker command: 4 | 5 | ```shell 6 | make logs 7 | ``` 8 | 9 | If you're experiencing oddities in the app, here are some common problems and their possible solutions. 10 | 11 | ## Common Issues 12 | 13 | ## I get a 409 error when linking a duplicate institution for the same user 14 | 15 | Plaid Pattern has implemented server logic such that duplicate account linkages are prohibited. See [preventing item duplication](https://github.com/plaid/pattern/tree/master/server#preventing-item-duplication) section for more information. 16 | 17 | ## I am not receiving transactions webhooks and my 'reset login' button does not work 18 | 19 | For webhooks to work, the server must be publicly accessible on the internet. For development purposes, this application uses [ngrok][ngrok-readme] to accomplish that. Therefore, if the server is re-started, any items created in this sample app previous to the current session will have a different webhook address attached to it. As a result, webhooks are only valid during the session in which an item is created; for previously created items, no transactions webhooks will be received, and no webhook will be received from the call to sandboxItemResetLogin. In addition, ngrok webhook addresses are only valid for 2 hours. If you are not receiving webhooks in this sample application, restart your server to reset the ngrok webhook address. 20 | 21 | ## Still need help? 22 | 23 | Please head to the [Help Center](https://support.plaid.com/hc/en-us) or [get in touch](https://dashboard.plaid.com/support/new) with Support. 24 | -------------------------------------------------------------------------------- /ngrok/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.9 2 | 3 | RUN set -x && \ 4 | apk add --no-cache -t .deps ca-certificates && \ 5 | # Install glibc on Alpine (required by docker-compose) from 6 | # https://github.com/sgerrand/alpine-pkg-glibc 7 | # See also https://github.com/gliderlabs/docker-alpine/issues/11 8 | wget -q -O /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \ 9 | wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.29-r0/glibc-2.29-r0.apk && \ 10 | apk add glibc-2.29-r0.apk && \ 11 | rm glibc-2.29-r0.apk && \ 12 | apk del --purge .deps 13 | 14 | # Install ngrok 15 | ARG TARGETARCH 16 | RUN set -x \ 17 | && case ${TARGETARCH} in \ 18 | "amd64") NGROK_URL="https://bin.equinox.io/c/bNyj1mQVY4c/ngrok-v3-stable-linux-amd64.tgz" ;; \ 19 | "arm64") NGROK_URL="https://bin.equinox.io/c/bNyj1mQVY4c/ngrok-v3-stable-linux-arm64.tgz" ;; \ 20 | *) echo "unsupported architecture: ${TARGETARCH}" && exit 1 ;; \ 21 | esac \ 22 | && apk add --no-cache curl \ 23 | && curl -Lo /ngrok.tgz ${NGROK_URL} \ 24 | && tar xvzf /ngrok.tgz -C /bin \ 25 | && rm -f /ngrok.tgz \ 26 | # Create non-root user. 27 | && adduser -h /home/ngrok -D -u 6737 ngrok 28 | RUN ngrok --version 29 | 30 | # Add config script. 31 | COPY --chown=ngrok ngrok.yml /home/ngrok/.ngrok2/ 32 | COPY entrypoint.sh / 33 | 34 | USER ngrok 35 | ENV USER=ngrok 36 | 37 | CMD ["/entrypoint.sh"] 38 | -------------------------------------------------------------------------------- /ngrok/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | if [ -n "$@" ]; then 4 | exec "$@" 5 | fi 6 | 7 | # Legacy compatible: 8 | if [ -z "$NGROK_PORT" ]; then 9 | if [ -n "$HTTPS_PORT" ]; then 10 | NGROK_PORT="$HTTPS_PORT" 11 | elif [ -n "$HTTPS_PORT" ]; then 12 | NGROK_PORT="$HTTP_PORT" 13 | elif [ -n "$APP_PORT" ]; then 14 | NGROK_PORT="$APP_PORT" 15 | fi 16 | fi 17 | 18 | 19 | ARGS="ngrok" 20 | 21 | # Set the protocol. 22 | if [ "$NGROK_PROTOCOL" = "TCP" ]; then 23 | ARGS="$ARGS tcp" 24 | else 25 | ARGS="$ARGS http" 26 | NGROK_PORT="${NGROK_PORT:-80}" 27 | fi 28 | 29 | # Set the TLS binding flag 30 | if [ -n "$NGROK_BINDTLS" ]; then 31 | ARGS="$ARGS -bind-tls=$NGROK_BINDTLS " 32 | fi 33 | 34 | # Set the authorization token. 35 | if [ -n "$NGROK_AUTH" ]; then 36 | echo "authtoken: $NGROK_AUTH" >> ~/.ngrok2/ngrok.yml 37 | fi 38 | 39 | # Set the subdomain or hostname, depending on which is set 40 | if [ -n "$NGROK_HOSTNAME" ] && [ -n "$NGROK_AUTH" ]; then 41 | ARGS="$ARGS -hostname=$NGROK_HOSTNAME " 42 | elif [ -n "$NGROK_SUBDOMAIN" ] && [ -n "$NGROK_AUTH" ]; then 43 | ARGS="$ARGS -subdomain=$NGROK_SUBDOMAIN " 44 | elif [ -n "$NGROK_HOSTNAME" ] || [ -n "$NGROK_SUBDOMAIN" ]; then 45 | if [ -z "$NGROK_AUTH" ]; then 46 | echo "You must specify an authentication token after registering at https://ngrok.com to use custom domains." 47 | exit 1 48 | fi 49 | fi 50 | 51 | # Set the remote-addr if specified 52 | if [ -n "$NGROK_REMOTE_ADDR" ]; then 53 | if [ -z "$NGROK_AUTH" ]; then 54 | echo "You must specify an authentication token after registering at https://ngrok.com to use reserved ip addresses." 55 | exit 1 56 | fi 57 | ARGS="$ARGS -remote-addr=$NGROK_REMOTE_ADDR " 58 | fi 59 | 60 | # Set a custom region 61 | if [ -n "$NGROK_REGION" ]; then 62 | ARGS="$ARGS -region=$NGROK_REGION " 63 | fi 64 | 65 | if [ -n "$NGROK_HEADER" ]; then 66 | ARGS="$ARGS -host-header=$NGROK_HEADER " 67 | fi 68 | 69 | if [ -n "$NGROK_USERNAME" ] && [ -n "$NGROK_PASSWORD" ] && [ -n "$NGROK_AUTH" ]; then 70 | ARGS="$ARGS -auth=$NGROK_USERNAME:$NGROK_PASSWORD " 71 | elif [ -n "$NGROK_USERNAME" ] || [ -n "$NGROK_PASSWORD" ]; then 72 | if [ -z "$NGROK_AUTH" ]; then 73 | echo "You must specify a username, password, and Ngrok authentication token to use the custom HTTP authentication." 74 | echo "Sign up for an authentication token at https://ngrok.com" 75 | exit 1 76 | fi 77 | fi 78 | 79 | if [ -n "$NGROK_DEBUG" ]; then 80 | ARGS="$ARGS -log stdout" 81 | fi 82 | 83 | # Set the port. 84 | if [ -z "$NGROK_PORT" ]; then 85 | echo "You must specify a NGROK_PORT to expose." 86 | exit 1 87 | fi 88 | ARGS="$ARGS `echo $NGROK_PORT | sed 's|^tcp://||'`" 89 | 90 | set -x 91 | exec $ARGS 92 | -------------------------------------------------------------------------------- /ngrok/ngrok.yml: -------------------------------------------------------------------------------- 1 | web_addr: 0.0.0.0:4040 2 | # Fill this in with your authtoken from https://dashboard.ngrok.com/get-started/your-authtoken 3 | authtoken: 4 | version: 2 5 | -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | # node 2 | node_modules 3 | npm-debug.log 4 | 5 | # docker 6 | Dockerfile* 7 | docker-compose* 8 | .dockerignore 9 | 10 | # other 11 | .DS_Store 12 | .env 13 | .eslintrc* 14 | .gitignore 15 | .prettierrc* 16 | README.md 17 | -------------------------------------------------------------------------------- /server/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": ["airbnb-base", "prettier"], 8 | "globals": { 9 | "Atomics": "readonly", 10 | "SharedArrayBuffer": "readonly" 11 | }, 12 | "parserOptions": { 13 | "ecmaVersion": 2018 14 | }, 15 | "plugins": ["prettier"], 16 | "rules": { 17 | "prettier/prettier": "error", 18 | "no-console": "off" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | -------------------------------------------------------------------------------- /server/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "bracketSpacing": true, 7 | "jsxBracketSameLine": false, 8 | "arrowParens": "avoid" 9 | } 10 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | COPY ["package.json", "/opt/server/"] 4 | COPY ["package-lock.json", "/opt/server/"] 5 | 6 | WORKDIR /opt/server 7 | 8 | RUN npm ci 9 | 10 | COPY ["./", "/opt/server/"] 11 | 12 | CMD ["npm", "start"] 13 | -------------------------------------------------------------------------------- /server/db/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines a connection to the PostgreSQL database, using environment 3 | * variables for connection information. 4 | */ 5 | 6 | const { Pool, types } = require('pg'); 7 | 8 | // node-pg returns numerics as strings by default. since we don't expect to 9 | // have large currency values, we'll parse them as floats instead. 10 | types.setTypeParser(1700, val => parseFloat(val)); 11 | 12 | // These environment variables are set in the repo root's docker-compose.yml 13 | const { DB_PORT, DB_HOST_NAME, POSTGRES_USER, POSTGRES_PASSWORD } = process.env; 14 | 15 | // Create a connection pool. This generates new connections for every request. 16 | const db = new Pool({ 17 | host: DB_HOST_NAME, 18 | port: DB_PORT, 19 | user: POSTGRES_USER, 20 | password: POSTGRES_PASSWORD, 21 | max: 5, 22 | min: 2, 23 | idleTimeoutMillis: 1000, // close idle clients after 1 second 24 | connectionTimeoutMillis: 1000, // return an error after 1 second if connection could not be established 25 | }); 26 | 27 | module.exports = db; 28 | -------------------------------------------------------------------------------- /server/db/queries/accounts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines the queries for the accounts table/view. 3 | */ 4 | 5 | const { retrieveItemByPlaidItemId } = require('./items'); 6 | const db = require('../'); 7 | 8 | /** 9 | * Creates multiple accounts related to a single item. 10 | * 11 | * @param {string} plaidItemId the Plaid ID of the item. 12 | * @param {Object[]} accounts an array of accounts. 13 | * @returns {Object[]} an array of new accounts. 14 | */ 15 | const createAccounts = async (plaidItemId, accounts) => { 16 | const { id: itemId } = await retrieveItemByPlaidItemId(plaidItemId); 17 | const pendingQueries = accounts.map(async account => { 18 | const { 19 | account_id: aid, 20 | name, 21 | mask, 22 | official_name: officialName, 23 | balances: { 24 | available: availableBalance, 25 | current: currentBalance, 26 | iso_currency_code: isoCurrencyCode, 27 | unofficial_currency_code: unofficialCurrencyCode, 28 | }, 29 | subtype, 30 | type, 31 | } = account; 32 | const query = { 33 | // RETURNING is a Postgres-specific clause that returns a list of the inserted items. 34 | text: ` 35 | INSERT INTO accounts_table 36 | ( 37 | item_id, 38 | plaid_account_id, 39 | name, 40 | mask, 41 | official_name, 42 | current_balance, 43 | available_balance, 44 | iso_currency_code, 45 | unofficial_currency_code, 46 | type, 47 | subtype 48 | ) 49 | VALUES 50 | ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) 51 | ON CONFLICT 52 | (plaid_account_id) 53 | DO UPDATE SET 54 | current_balance = $6, 55 | available_balance = $7 56 | RETURNING 57 | * 58 | `, 59 | values: [ 60 | itemId, 61 | aid, 62 | name, 63 | mask, 64 | officialName, 65 | currentBalance, 66 | availableBalance, 67 | isoCurrencyCode, 68 | unofficialCurrencyCode, 69 | type, 70 | subtype, 71 | ], 72 | }; 73 | const { rows } = await db.query(query); 74 | return rows[0]; 75 | }); 76 | return await Promise.all(pendingQueries); 77 | }; 78 | 79 | /** 80 | * Retrieves the account associated with a Plaid account ID. 81 | * 82 | * @param {string} plaidAccountId the Plaid ID of the account. 83 | * @returns {Object} a single account. 84 | */ 85 | const retrieveAccountByPlaidAccountId = async plaidAccountId => { 86 | const query = { 87 | text: 'SELECT * FROM accounts WHERE plaid_account_id = $1', 88 | values: [plaidAccountId], 89 | }; 90 | const { rows } = await db.query(query); 91 | // since Plaid account IDs are unique, this query will never return more than one row. 92 | return rows[0]; 93 | }; 94 | 95 | /** 96 | * Retrieves the accounts for a single item. 97 | * 98 | * @param {number} itemId the ID of the item. 99 | * @returns {Object[]} an array of accounts. 100 | */ 101 | const retrieveAccountsByItemId = async itemId => { 102 | const query = { 103 | text: 'SELECT * FROM accounts WHERE item_id = $1 ORDER BY id', 104 | values: [itemId], 105 | }; 106 | const { rows: accounts } = await db.query(query); 107 | return accounts; 108 | }; 109 | 110 | /** 111 | * Retrieves all accounts for a single user. 112 | * 113 | * @param {number} userId the ID of the user. 114 | * 115 | * @returns {Object[]} an array of accounts. 116 | */ 117 | const retrieveAccountsByUserId = async userId => { 118 | const query = { 119 | text: 'SELECT * FROM accounts WHERE user_id = $1 ORDER BY id', 120 | values: [userId], 121 | }; 122 | const { rows: accounts } = await db.query(query); 123 | return accounts; 124 | }; 125 | 126 | module.exports = { 127 | createAccounts, 128 | retrieveAccountByPlaidAccountId, 129 | retrieveAccountsByItemId, 130 | retrieveAccountsByUserId, 131 | }; 132 | -------------------------------------------------------------------------------- /server/db/queries/assets.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines the queries for the assets table/view. 3 | */ 4 | 5 | const db = require('../'); 6 | 7 | /** 8 | * Creates a single property. 9 | * 10 | * @param {number} userId the ID of the user. 11 | * @returns {Object} the new property. 12 | */ 13 | const createAsset = async (userId, description, value) => { 14 | const query = { 15 | // RETURNING is a Postgres-specific clause that returns a list of the inserted assets. 16 | text: ` 17 | INSERT INTO assets_table 18 | (user_id, description, value) 19 | VALUES 20 | ($1, $2, $3) 21 | RETURNING 22 | *; 23 | `, 24 | values: [userId, description, value], 25 | }; 26 | const { rows } = await db.query(query); 27 | return rows[0]; 28 | }; 29 | 30 | /** 31 | * Retrieves all assets for a single user. 32 | * 33 | * @param {number} userId the ID of the user. 34 | * @returns {Object[]} an array of assets. 35 | */ 36 | const retrieveAssetsByUser = async userId => { 37 | const query = { 38 | text: 'SELECT * FROM assets WHERE user_id = $1', 39 | values: [userId], 40 | }; 41 | const { rows: assets } = await db.query(query); 42 | return assets; 43 | }; 44 | 45 | /** 46 | * Removes asset by asset id. 47 | *to 48 | * 49 | * @param {number} id the desired asset be deleted. 50 | */ 51 | 52 | const deleteAssetByAssetId = async assetId => { 53 | const query = { 54 | text: 'DELETE FROM assets_table WHERE id = $1;', 55 | values: [assetId], 56 | }; 57 | await db.query(query); 58 | }; 59 | 60 | module.exports = { 61 | createAsset, 62 | retrieveAssetsByUser, 63 | deleteAssetByAssetId, 64 | }; 65 | -------------------------------------------------------------------------------- /server/db/queries/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Exports the queries for interacting with the database. 3 | */ 4 | 5 | const { 6 | createAccounts, 7 | retrieveAccountByPlaidAccountId, 8 | retrieveAccountsByItemId, 9 | retrieveAccountsByUserId, 10 | } = require('./accounts'); 11 | const { 12 | createItem, 13 | deleteItem, 14 | retrieveItemById, 15 | retrieveItemByPlaidAccessToken, 16 | retrieveItemByPlaidInstitutionId, 17 | retrieveItemByPlaidItemId, 18 | retrieveItemsByUser, 19 | updateItemStatus, 20 | updateItemTransactionsCursor, 21 | } = require('./items'); 22 | const { createPlaidApiEvent } = require('./plaidApiEvents'); 23 | const { 24 | createOrUpdateTransactions, 25 | retrieveTransactionsByAccountId, 26 | retrieveTransactionsByItemId, 27 | retrieveTransactionsByUserId, 28 | deleteTransactions, 29 | } = require('./transactions'); 30 | const { 31 | createUser, 32 | deleteUsers, 33 | retrieveUsers, 34 | retrieveUserById, 35 | retrieveUserByUsername, 36 | } = require('./users'); 37 | const { createLinkEvent } = require('./linkEvents'); 38 | 39 | const { 40 | createAsset, 41 | retrieveAssetsByUser, 42 | deleteAssetByAssetId, 43 | } = require('./assets'); 44 | 45 | module.exports = { 46 | // accounts 47 | createAccounts, 48 | retrieveAccountByPlaidAccountId, 49 | retrieveAccountsByItemId, 50 | retrieveAccountsByUserId, 51 | // items 52 | createItem, 53 | deleteItem, 54 | retrieveItemById, 55 | retrieveItemByPlaidAccessToken, 56 | retrieveItemByPlaidInstitutionId, 57 | retrieveItemByPlaidItemId, 58 | retrieveItemsByUser, 59 | updateItemStatus, 60 | // plaid api events 61 | createPlaidApiEvent, 62 | // transactions 63 | retrieveTransactionsByAccountId, 64 | retrieveTransactionsByItemId, 65 | retrieveTransactionsByUserId, 66 | deleteTransactions, 67 | createOrUpdateTransactions, 68 | updateItemTransactionsCursor, 69 | // users 70 | createUser, 71 | deleteUsers, 72 | retrieveUserById, 73 | retrieveUserByUsername, 74 | retrieveUsers, 75 | // assets 76 | createAsset, 77 | retrieveAssetsByUser, 78 | deleteAssetByAssetId, 79 | // link events 80 | createLinkEvent, 81 | }; 82 | -------------------------------------------------------------------------------- /server/db/queries/items.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines the queries for the items table/view. 3 | */ 4 | 5 | const db = require('../'); 6 | 7 | /** 8 | * Creates a single item. 9 | * 10 | * @param {string} plaidInstitutionId the Plaid institution ID of the item. 11 | * @param {string} plaidAccessToken the Plaid access token of the item. 12 | * @param {string} plaidItemId the Plaid ID of the item. 13 | * @param {number} userId the ID of the user. 14 | * @returns {Object} the new item. 15 | */ 16 | const createItem = async ( 17 | plaidInstitutionId, 18 | plaidAccessToken, 19 | plaidItemId, 20 | userId 21 | ) => { 22 | // this method only gets called on successfully linking an item. 23 | // We know the status is good. 24 | const status = 'good'; 25 | const query = { 26 | // RETURNING is a Postgres-specific clause that returns a list of the inserted items. 27 | text: ` 28 | INSERT INTO items_table 29 | (user_id, plaid_access_token, plaid_item_id, plaid_institution_id, status) 30 | VALUES 31 | ($1, $2, $3, $4, $5) 32 | RETURNING 33 | *; 34 | `, 35 | values: [userId, plaidAccessToken, plaidItemId, plaidInstitutionId, status], 36 | }; 37 | const { rows } = await db.query(query); 38 | return rows[0]; 39 | }; 40 | 41 | /** 42 | * Retrieves a single item. 43 | * 44 | * @param {number} itemId the ID of the item. 45 | * @returns {Object} an item. 46 | */ 47 | const retrieveItemById = async itemId => { 48 | const query = { 49 | text: 'SELECT * FROM items WHERE id = $1', 50 | values: [itemId], 51 | }; 52 | const { rows } = await db.query(query); 53 | // since item IDs are unique, this query will never return more than one row. 54 | return rows[0]; 55 | }; 56 | 57 | /** 58 | * Retrieves a single item. 59 | * 60 | * @param {string} accessToken the Plaid access token of the item. 61 | * @returns {Object} the item. 62 | */ 63 | const retrieveItemByPlaidAccessToken = async accessToken => { 64 | const query = { 65 | text: 'SELECT * FROM items WHERE plaid_access_token = $1', 66 | values: [accessToken], 67 | }; 68 | const { rows: existingItems } = await db.query(query); 69 | // Access tokens are unique, so this will return at most one item. 70 | return existingItems[0]; 71 | }; 72 | 73 | /** 74 | * Retrieves a single item. 75 | * 76 | * @param {string} plaidInstitutionId the Plaid institution ID of the item. 77 | * @param {number} userId the ID of the user. 78 | * @returns {Object} an item. 79 | */ 80 | const retrieveItemByPlaidInstitutionId = async (plaidInstitutionId, userId) => { 81 | const query = { 82 | text: 83 | 'SELECT * FROM items WHERE plaid_institution_id = $1 AND user_id = $2', 84 | values: [plaidInstitutionId, userId], 85 | }; 86 | const { rows: existingItems } = await db.query(query); 87 | return existingItems[0]; 88 | }; 89 | 90 | /** 91 | * Retrieves a single item. 92 | * 93 | * @param {string} plaidItemId the Plaid ID of the item. 94 | * @returns {Object} an item. 95 | */ 96 | const retrieveItemByPlaidItemId = async plaidItemId => { 97 | const query = { 98 | text: 'SELECT * FROM items WHERE plaid_item_id = $1', 99 | values: [plaidItemId], 100 | }; 101 | const { rows } = await db.query(query); 102 | // since Plaid item IDs are unique, this query will never return more than one row. 103 | return rows[0]; 104 | }; 105 | 106 | /** 107 | * Retrieves all items for a single user. 108 | * 109 | * @param {number} userId the ID of the user. 110 | * @returns {Object[]} an array of items. 111 | */ 112 | const retrieveItemsByUser = async userId => { 113 | const query = { 114 | text: 'SELECT * FROM items WHERE user_id = $1', 115 | values: [userId], 116 | }; 117 | const { rows: items } = await db.query(query); 118 | return items; 119 | }; 120 | 121 | /** 122 | * Updates the status for a single item. 123 | * 124 | * @param {string} itemId the Plaid item ID of the item. 125 | * @param {string} status the status of the item. 126 | */ 127 | const updateItemStatus = async (itemId, status) => { 128 | const query = { 129 | text: 'UPDATE items SET status = $1 WHERE id = $2', 130 | values: [status, itemId], 131 | }; 132 | await db.query(query); 133 | }; 134 | 135 | /** 136 | * Updates the transaction cursor for a single item. 137 | * 138 | * @param {string} plaidItemId the Plaid item ID of the item. 139 | * @param {string} transactionsCursor latest observed transactions cursor on this item. 140 | */ 141 | const updateItemTransactionsCursor = async (plaidItemId, transactionsCursor) => { 142 | const query = { 143 | text: 'UPDATE items SET transactions_cursor = $1 WHERE plaid_item_id = $2', 144 | values: [transactionsCursor, plaidItemId], 145 | }; 146 | await db.query(query); 147 | }; 148 | 149 | /** 150 | * Removes a single item. The database will also remove related accounts and transactions. 151 | * 152 | * @param {string} itemId the id of the item. 153 | */ 154 | const deleteItem = async itemId => { 155 | const query = { 156 | text: `DELETE FROM items_table WHERE id = $1`, 157 | values: [itemId], 158 | }; 159 | await db.query(query); 160 | }; 161 | 162 | module.exports = { 163 | createItem, 164 | deleteItem, 165 | retrieveItemById, 166 | retrieveItemByPlaidAccessToken, 167 | retrieveItemByPlaidInstitutionId, 168 | retrieveItemByPlaidItemId, 169 | retrieveItemsByUser, 170 | updateItemStatus, 171 | updateItemTransactionsCursor, 172 | }; 173 | -------------------------------------------------------------------------------- /server/db/queries/linkEvents.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines the queries for link events. 3 | */ 4 | 5 | const db = require('..'); 6 | 7 | /** 8 | * Creates a link event. 9 | * 10 | * @param {Object} event the link event. 11 | * @param {string} event.userId the ID of the user. 12 | * @param {string} event.type displayed as 'success' or 'exit' based on the callback. 13 | * @param {string} event.link_session_id the session ID created when connecting with link. 14 | * @param {string} event.request_id the request ID created only on error when connecting with link. 15 | * @param {string} event.error_type a broad categorization of the error. 16 | * @param {string} event.error_code a specific code that is a subset of the error_type. 17 | */ 18 | 19 | const createLinkEvent = async ({ 20 | type, 21 | userId, 22 | link_session_id: linkSessionId, 23 | request_id: requestId, 24 | error_type: errorType, 25 | error_code: errorCode, 26 | status: status, 27 | }) => { 28 | const query = { 29 | text: ` 30 | INSERT INTO link_events_table 31 | ( 32 | type, 33 | user_id, 34 | link_session_id, 35 | request_id, 36 | error_type, 37 | error_code, 38 | status 39 | ) 40 | VALUES ($1, $2, $3, $4, $5, $6,$7); 41 | `, 42 | values: [ 43 | type, 44 | userId, 45 | linkSessionId, 46 | requestId, 47 | errorType, 48 | errorCode, 49 | status, 50 | ], 51 | }; 52 | await db.query(query); 53 | }; 54 | 55 | module.exports = { 56 | createLinkEvent, 57 | }; 58 | -------------------------------------------------------------------------------- /server/db/queries/plaidApiEvents.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines the queries for the plaid_api_events table. 3 | */ 4 | 5 | const db = require('../'); 6 | 7 | /** 8 | * Creates a single Plaid api event log entry. 9 | * 10 | * @param {string} itemId the item id in the request. 11 | * @param {string} userId the user id in the request. 12 | * @param {string} plaidMethod the Plaid client method called. 13 | * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. 14 | * @param {Object} response the Plaid api response object. 15 | */ 16 | const createPlaidApiEvent = async ( 17 | itemId, 18 | userId, 19 | plaidMethod, 20 | clientMethodArgs, 21 | response 22 | ) => { 23 | const { 24 | error_code: errorCode, 25 | error_type: errorType, 26 | request_id: requestId, 27 | } = response; 28 | const query = { 29 | text: ` 30 | INSERT INTO plaid_api_events_table 31 | ( 32 | item_id, 33 | user_id, 34 | plaid_method, 35 | arguments, 36 | request_id, 37 | error_type, 38 | error_code 39 | ) 40 | VALUES 41 | ($1, $2, $3, $4, $5, $6, $7); 42 | `, 43 | values: [ 44 | itemId, 45 | userId, 46 | plaidMethod, 47 | JSON.stringify(clientMethodArgs), 48 | requestId, 49 | errorType, 50 | errorCode, 51 | ], 52 | }; 53 | await db.query(query); 54 | }; 55 | 56 | module.exports = { 57 | createPlaidApiEvent, 58 | }; 59 | -------------------------------------------------------------------------------- /server/db/queries/transactions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines the queries for the transactions table. 3 | */ 4 | 5 | const { retrieveAccountByPlaidAccountId } = require('./accounts'); 6 | const db = require('../'); 7 | 8 | /** 9 | * Creates or updates multiple transactions. 10 | * 11 | * @param {Object[]} transactions an array of transactions. 12 | */ 13 | const createOrUpdateTransactions = async transactions => { 14 | const pendingQueries = transactions.map(async transaction => { 15 | const { 16 | account_id: plaidAccountId, 17 | transaction_id: plaidTransactionId, 18 | personal_finance_category: { primary: category }, 19 | transaction_type: transactionType, 20 | name: transactionName, 21 | amount, 22 | iso_currency_code: isoCurrencyCode, 23 | unofficial_currency_code: unofficialCurrencyCode, 24 | date: transactionDate, 25 | pending, 26 | account_owner: accountOwner, 27 | } = transaction; 28 | const { id: accountId } = await retrieveAccountByPlaidAccountId( 29 | plaidAccountId 30 | ); 31 | try { 32 | const query = { 33 | text: ` 34 | INSERT INTO transactions_table 35 | ( 36 | account_id, 37 | plaid_transaction_id, 38 | category, 39 | type, 40 | name, 41 | amount, 42 | iso_currency_code, 43 | unofficial_currency_code, 44 | date, 45 | pending, 46 | account_owner 47 | ) 48 | VALUES 49 | ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) 50 | ON CONFLICT (plaid_transaction_id) DO UPDATE 51 | SET 52 | category = EXCLUDED.category, 53 | type = EXCLUDED.type, 54 | name = EXCLUDED.name, 55 | amount = EXCLUDED.amount, 56 | iso_currency_code = EXCLUDED.iso_currency_code, 57 | unofficial_currency_code = EXCLUDED.unofficial_currency_code, 58 | date = EXCLUDED.date, 59 | pending = EXCLUDED.pending, 60 | account_owner = EXCLUDED.account_owner; 61 | `, 62 | values: [ 63 | accountId, 64 | plaidTransactionId, 65 | category, 66 | transactionType, 67 | transactionName, 68 | amount, 69 | isoCurrencyCode, 70 | unofficialCurrencyCode, 71 | transactionDate, 72 | pending, 73 | accountOwner, 74 | ], 75 | }; 76 | await db.query(query); 77 | } catch (err) { 78 | console.error(err); 79 | } 80 | }); 81 | await Promise.all(pendingQueries); 82 | }; 83 | 84 | /** 85 | * Retrieves all transactions for a single account. 86 | * 87 | * @param {number} accountId the ID of the account. 88 | * @returns {Object[]} an array of transactions. 89 | */ 90 | const retrieveTransactionsByAccountId = async accountId => { 91 | const query = { 92 | text: 'SELECT * FROM transactions WHERE account_id = $1 ORDER BY date DESC', 93 | values: [accountId], 94 | }; 95 | const { rows: transactions } = await db.query(query); 96 | return transactions; 97 | }; 98 | 99 | /** 100 | * Retrieves all transactions for a single item. 101 | * 102 | * 103 | * @param {number} itemId the ID of the item. 104 | * @returns {Object[]} an array of transactions. 105 | */ 106 | const retrieveTransactionsByItemId = async itemId => { 107 | const query = { 108 | text: 'SELECT * FROM transactions WHERE item_id = $1 ORDER BY date DESC', 109 | values: [itemId], 110 | }; 111 | const { rows: transactions } = await db.query(query); 112 | return transactions; 113 | }; 114 | 115 | /** 116 | * Retrieves all transactions for a single user. 117 | * 118 | * 119 | * @param {number} userId the ID of the user. 120 | * @returns {Object[]} an array of transactions. 121 | */ 122 | const retrieveTransactionsByUserId = async userId => { 123 | const query = { 124 | text: 'SELECT * FROM transactions WHERE user_id = $1 ORDER BY date DESC', 125 | values: [userId], 126 | }; 127 | const { rows: transactions } = await db.query(query); 128 | return transactions; 129 | }; 130 | 131 | /** 132 | * Removes one or more transactions. 133 | * 134 | * @param {string[]} plaidTransactionIds the Plaid IDs of the transactions. 135 | */ 136 | const deleteTransactions = async plaidTransactionIds => { 137 | const pendingQueries = plaidTransactionIds.map(async transactionId => { 138 | const query = { 139 | text: 'DELETE FROM transactions_table WHERE plaid_transaction_id = $1', 140 | values: [transactionId], 141 | }; 142 | await db.query(query); 143 | }); 144 | await Promise.all(pendingQueries); 145 | }; 146 | 147 | module.exports = { 148 | createOrUpdateTransactions, 149 | retrieveTransactionsByAccountId, 150 | retrieveTransactionsByItemId, 151 | retrieveTransactionsByUserId, 152 | deleteTransactions, 153 | }; 154 | -------------------------------------------------------------------------------- /server/db/queries/users.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines the queries for the users table/views. 3 | */ 4 | 5 | const db = require('../'); 6 | 7 | /** 8 | * Creates a single user. 9 | * 10 | * @param {string} username the username of the user. 11 | * @returns {Object} the new user. 12 | */ 13 | const createUser = async username => { 14 | const query = { 15 | // RETURNING is a Postgres-specific clause that returns a list of the inserted items. 16 | text: 'INSERT INTO users_table (username) VALUES ($1) RETURNING *;', 17 | values: [username], 18 | }; 19 | const { rows } = await db.query(query); 20 | return rows[0]; 21 | }; 22 | 23 | /** 24 | * Removes users and related items, accounts and transactions. 25 | * 26 | * 27 | * @param {string[]} userId the desired user to be deleted. 28 | */ 29 | 30 | const deleteUsers = async userId => { 31 | const query = { 32 | text: 'DELETE FROM users_table WHERE id = $1;', 33 | values: [userId], 34 | }; 35 | await db.query(query); 36 | }; 37 | 38 | /** 39 | * Retrieves a single user. 40 | * 41 | * @param {number} userId the ID of the user. 42 | * @returns {Object} a user. 43 | */ 44 | const retrieveUserById = async userId => { 45 | const query = { 46 | text: 'SELECT * FROM users WHERE id = $1', 47 | values: [userId], 48 | }; 49 | const { rows } = await db.query(query); 50 | // since the user IDs are unique, this query will return at most one result. 51 | return rows[0]; 52 | }; 53 | 54 | /** 55 | * Retrieves a single user. 56 | * 57 | * @param {string} username the username to search for. 58 | * @returns {Object} a single user. 59 | */ 60 | const retrieveUserByUsername = async username => { 61 | const query = { 62 | text: 'SELECT * FROM users WHERE username = $1', 63 | values: [username], 64 | }; 65 | const { rows: users } = await db.query(query); 66 | // the username column has a UNIQUE constraint, so this will never return more than one row. 67 | return users[0]; 68 | }; 69 | 70 | /** 71 | * Retrieves all users. 72 | * 73 | * @returns {Object[]} an array of users. 74 | */ 75 | const retrieveUsers = async () => { 76 | const query = { 77 | text: 'SELECT * FROM users', 78 | }; 79 | const { rows: users } = await db.query(query); 80 | return users; 81 | }; 82 | 83 | module.exports = { 84 | createUser, 85 | deleteUsers, 86 | retrieveUserById, 87 | retrieveUserByUsername, 88 | retrieveUsers, 89 | }; 90 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file The application root. Defines the Express server configuration. 3 | */ 4 | 5 | const express = require('express'); 6 | const socketIo = require('socket.io'); 7 | const cookieParser = require('cookie-parser'); 8 | const logger = require('morgan'); 9 | 10 | const { errorHandler } = require('./middleware'); 11 | 12 | const { 13 | usersRouter, 14 | sessionsRouter, 15 | itemsRouter, 16 | accountsRouter, 17 | institutionsRouter, 18 | serviceRouter, 19 | linkEventsRouter, 20 | linkTokensRouter, 21 | unhandledRouter, 22 | assetsRouter, 23 | } = require('./routes'); 24 | 25 | const app = express(); 26 | 27 | const { PORT } = process.env; 28 | 29 | const server = app.listen(PORT, () => { 30 | console.log(`listening on port ${PORT}`); 31 | }); 32 | 33 | const io = socketIo(server, { 34 | cors: { 35 | origin: "http://localhost:3001", 36 | methods: ["*"], 37 | allowedHeaders: ["*"], 38 | credentials: true, 39 | }, 40 | allowEIO3: true 41 | }); 42 | 43 | 44 | 45 | app.use(logger('dev')); 46 | app.use(express.json()); 47 | app.use(express.urlencoded({ extended: false })); 48 | app.use(cookieParser()); 49 | 50 | // middleware to pass socket to each request object 51 | app.use((req, res, next) => { 52 | req.io = io; 53 | next(); 54 | }); 55 | 56 | // Set socket.io listeners. 57 | io.on('connection', socket => { 58 | console.log('SOCKET CONNECTED'); 59 | 60 | socket.on('disconnect', () => { 61 | console.log('SOCKET DISCONNECTED'); 62 | }); 63 | }); 64 | 65 | app.get('/test', (req, res) => { 66 | res.send('test response'); 67 | }); 68 | 69 | app.use('/users', usersRouter); 70 | app.use('/sessions', sessionsRouter); 71 | app.use('/items', itemsRouter); 72 | app.use('/accounts', accountsRouter); 73 | app.use('/institutions', institutionsRouter); 74 | app.use('/services', serviceRouter); 75 | app.use('/link-event', linkEventsRouter); 76 | app.use('/link-token', linkTokensRouter); 77 | app.use('/assets', assetsRouter); 78 | app.use('*', unhandledRouter); 79 | 80 | // Error handling has to sit at the bottom of the stack. 81 | // https://github.com/expressjs/express/issues/2718 82 | app.use(errorHandler); 83 | -------------------------------------------------------------------------------- /server/middleware.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines custom Express middleware functions. 3 | */ 4 | 5 | const Boom = require('@hapi/boom'); 6 | 7 | /** 8 | * A higher-order function that wraps an async callback to properly trigger the 9 | * Express error-handling middleware on errors. 10 | * 11 | * @param {Function} fn an async callback. 12 | * @returns {Function} an Express callback that resolves the wrapped async fn. 13 | */ 14 | const asyncWrapper = fn => (req, res, next) => { 15 | return Promise.resolve(fn(req, res, next)).catch(next); 16 | }; 17 | 18 | /** 19 | * A catch-all error handler that sends a formatted JSON response. 20 | * Uses Boom to set the status code and provide consistent formatting. 21 | * 22 | * If using multiple error handlers, this should be the last one. 23 | * 24 | * @param {Object} err a javascript Error object. 25 | * @param {Object} req the Express request object. 26 | * @param {Object} res the Express response object. 27 | * @param {Function} next the Express next callback. 28 | */ 29 | const errorHandler = (err, req, res, next) => { 30 | let error = err; 31 | 32 | // handle errors from the Plaid api. 33 | if (error.name === 'PlaidError') 34 | error = new Boom(error.error_message, { statusCode: error.status_code }); 35 | 36 | // handle standard javascript errors. 37 | if (!error.isBoom) error = Boom.boomify(error); 38 | 39 | // these are generated by Boom, so they're guaranteed to exist. 40 | const { statusCode, payload } = error.output; 41 | res.status(statusCode).json(payload); 42 | }; 43 | 44 | module.exports = { asyncWrapper, errorHandler }; 45 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.7", 4 | "private": true, 5 | "scripts": { 6 | "start": "node index.js", 7 | "debug": "nodemon --inspect=0.0.0.0:9229 index.js", 8 | "lint": "eslint **/*.js -c .eslintrc.json" 9 | }, 10 | "dependencies": { 11 | "@hapi/boom": "^7.4.2", 12 | "cookie-parser": "~1.4.3", 13 | "debug": "~2.6.9", 14 | "express": "^4.21.1", 15 | "express-promise-router": "^3.0.3", 16 | "lodash": "^4.17.19", 17 | "moment": "^2.24.0", 18 | "morgan": "~1.9.0", 19 | "node-fetch": "^2.3.0", 20 | "pg": "^8.7.1", 21 | "plaid": "^30.0.0", 22 | "socket.io": "^4.5.4" 23 | }, 24 | "type": "commonjs", 25 | 26 | "devDependencies": { 27 | "eslint": "^6.2.1", 28 | "eslint-config-airbnb-base": "^14.0.0", 29 | "eslint-config-prettier": "^6.1.0", 30 | "eslint-plugin-import": "^2.18.0", 31 | "eslint-plugin-prettier": "^3.0.1", 32 | "nodemon": "^2.0.4", 33 | "prettier": "^1.16.4" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server/plaid.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines the connection to the Plaid client. 3 | */ 4 | 5 | const forEach = require('lodash/forEach'); 6 | const { Configuration, PlaidApi, PlaidEnvironments } = require('plaid'); 7 | 8 | const { 9 | createPlaidApiEvent, 10 | retrieveItemByPlaidAccessToken, 11 | } = require('./db/queries'); 12 | 13 | // Your Plaid API keys and secrets are loaded as environment variables. 14 | // They are set in your `.env` file. See the repo README for more info. 15 | const { 16 | PLAID_CLIENT_ID, 17 | PLAID_ENV, 18 | PLAID_SECRET_PRODUCTION, 19 | PLAID_SECRET_SANDBOX, 20 | } = process.env; 21 | 22 | // The Plaid secret is unique per environment. 23 | const PLAID_SECRET = 24 | PLAID_ENV === 'production' ? PLAID_SECRET_PRODUCTION : PLAID_SECRET_SANDBOX; 25 | 26 | const OPTIONS = { clientApp: 'Plaid-Pattern' }; 27 | 28 | // We want to log requests to / responses from the Plaid API (via the Plaid client), as this data 29 | // can be useful for troubleshooting. 30 | 31 | /** 32 | * Logging function for Plaid client methods that use an access_token as an argument. Associates 33 | * the Plaid API event log entry with the item and user the request is for. 34 | * 35 | * @param {string} clientMethod the name of the Plaid client method called. 36 | * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. 37 | * @param {Object} response the response from the Plaid client. 38 | */ 39 | const defaultLogger = async (clientMethod, clientMethodArgs, response) => { 40 | const accessToken = clientMethodArgs[0].access_token; 41 | const { id: itemId, user_id: userId } = await retrieveItemByPlaidAccessToken( 42 | accessToken 43 | ); 44 | await createPlaidApiEvent( 45 | itemId, 46 | userId, 47 | clientMethod, 48 | clientMethodArgs, 49 | response 50 | ); 51 | }; 52 | 53 | /** 54 | * Logging function for Plaid client methods that do not use access_token as an argument. These 55 | * Plaid API event log entries will not be associated with an item or user. 56 | * 57 | * @param {string} clientMethod the name of the Plaid client method called. 58 | * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. 59 | * @param {Object} response the response from the Plaid client. 60 | */ 61 | const noAccessTokenLogger = async ( 62 | clientMethod, 63 | clientMethodArgs, 64 | response 65 | ) => { 66 | await createPlaidApiEvent( 67 | undefined, 68 | undefined, 69 | clientMethod, 70 | clientMethodArgs, 71 | response 72 | ); 73 | }; 74 | 75 | // Plaid client methods used in this app, mapped to their appropriate logging functions. 76 | 77 | const clientMethodLoggingFns = { 78 | accountsGet: defaultLogger, 79 | institutionsGet: noAccessTokenLogger, 80 | institutionsGetById: noAccessTokenLogger, 81 | itemPublicTokenExchange: noAccessTokenLogger, 82 | itemRemove: defaultLogger, 83 | linkTokenCreate: noAccessTokenLogger, 84 | transactionsSync: defaultLogger, 85 | sandboxItemResetLogin: defaultLogger, 86 | }; 87 | // Wrapper for the Plaid client. This allows us to easily log data for all Plaid client requests. 88 | class PlaidClientWrapper { 89 | constructor() { 90 | // Initialize the Plaid client. 91 | 92 | const configuration = new Configuration({ 93 | basePath: PlaidEnvironments[PLAID_ENV], 94 | baseOptions: { 95 | headers: { 96 | 'PLAID-CLIENT-ID': PLAID_CLIENT_ID, 97 | 'PLAID-SECRET': PLAID_SECRET, 98 | 'Plaid-Version': '2020-09-14', 99 | }, 100 | }, 101 | }); 102 | 103 | this.client = new PlaidApi(configuration); 104 | 105 | // Wrap the Plaid client methods to add a logging function. 106 | forEach(clientMethodLoggingFns, (logFn, method) => { 107 | this[method] = this.createWrappedClientMethod(method, logFn); 108 | }); 109 | } 110 | 111 | // Allows us to log API request data for troubleshooting purposes. 112 | createWrappedClientMethod(clientMethod, log) { 113 | return async (...args) => { 114 | try { 115 | const res = await this.client[clientMethod](...args); 116 | await log(clientMethod, args, res); 117 | return res; 118 | } catch (err) { 119 | await log(clientMethod, args, err.response.data); 120 | throw err; 121 | } 122 | }; 123 | } 124 | } 125 | 126 | module.exports = new PlaidClientWrapper(); 127 | -------------------------------------------------------------------------------- /server/routes/accounts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines all routes for the Accounts route. 3 | */ 4 | 5 | const express = require('express'); 6 | const { retrieveTransactionsByAccountId } = require('../db/queries'); 7 | const { asyncWrapper } = require('../middleware'); 8 | const { sanitizeTransactions } = require('../util'); 9 | 10 | const router = express.Router(); 11 | 12 | /** 13 | * Fetches all transactions for a single account. 14 | * 15 | * @param {number} accountId the ID of the account. 16 | * @return {Object{[]}} an array of transactions 17 | */ 18 | router.get( 19 | '/:accountId/transactions', 20 | asyncWrapper(async (req, res) => { 21 | const { accountId } = req.params; 22 | const transactions = await retrieveTransactionsByAccountId(accountId); 23 | res.json(sanitizeTransactions(transactions)); 24 | }) 25 | ); 26 | 27 | module.exports = router; 28 | -------------------------------------------------------------------------------- /server/routes/assets.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines all routes for the Properties route. 3 | */ 4 | 5 | const express = require('express'); 6 | const { 7 | retrieveAssetsByUser, 8 | createAsset, 9 | deleteAssetByAssetId, 10 | } = require('../db/queries'); 11 | const { asyncWrapper } = require('../middleware'); 12 | 13 | const router = express.Router(); 14 | 15 | /** 16 | * 17 | * @param {string} userId the user ID of the active user. 18 | * @param {string} description the description of the property. 19 | * @param {number} value the numerical value of the property. 20 | */ 21 | router.post( 22 | '/', 23 | asyncWrapper(async (req, res) => { 24 | const { userId, description, value } = req.body; 25 | const newAsset = await createAsset(userId, description, value); 26 | res.json(newAsset); 27 | }) 28 | ); 29 | 30 | /** 31 | * Retrieves property for a single user 32 | * 33 | * @param {string} userId the ID of the user. 34 | * @returns {Object[]} an array of properties 35 | */ 36 | router.get( 37 | '/:userId', 38 | asyncWrapper(async (req, res) => { 39 | const { userId } = req.params; 40 | const assets = await retrieveAssetsByUser(userId); 41 | 42 | res.json(assets); 43 | }) 44 | ); 45 | 46 | /** 47 | * Deletes an asset by its id 48 | * 49 | * @param {string} assetId the ID of the asset. 50 | */ 51 | router.delete( 52 | '/:assetId', 53 | asyncWrapper(async (req, res) => { 54 | const { assetId } = req.params; 55 | await deleteAssetByAssetId(assetId); 56 | 57 | res.sendStatus(204); 58 | }) 59 | ); 60 | 61 | module.exports = router; 62 | -------------------------------------------------------------------------------- /server/routes/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines all root routes for the application. 3 | */ 4 | 5 | const usersRouter = require('./users'); 6 | const sessionsRouter = require('./sessions'); 7 | const itemsRouter = require('./items'); 8 | const accountsRouter = require('./accounts'); 9 | const institutionsRouter = require('./institutions'); 10 | const serviceRouter = require('./services'); 11 | const linkEventsRouter = require('./linkEvents'); 12 | const unhandledRouter = require('./unhandled'); 13 | const linkTokensRouter = require('./linkTokens'); 14 | const assetsRouter = require('./assets'); 15 | 16 | module.exports = { 17 | usersRouter, 18 | itemsRouter, 19 | accountsRouter, 20 | institutionsRouter, 21 | serviceRouter, 22 | linkEventsRouter, 23 | linkTokensRouter, 24 | unhandledRouter, 25 | sessionsRouter, 26 | assetsRouter, 27 | }; 28 | -------------------------------------------------------------------------------- /server/routes/institutions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines all routes for the Institutions route. 3 | */ 4 | 5 | const express = require('express'); 6 | const { asyncWrapper } = require('../middleware'); 7 | const plaid = require('../plaid'); 8 | const { toArray } = require('../util'); 9 | 10 | const router = express.Router(); 11 | 12 | /** 13 | * Fetches institutions from the Plaid API. 14 | * 15 | * @param {number} [count=200] The number of Institutions to return, 0 < count <= 500. 16 | * @param {number} [offset=0] The number of Institutions to skip before returning results, offset >= 0. 17 | * @returns {Object[]} an array of institutions. 18 | */ 19 | router.get( 20 | '/', 21 | asyncWrapper(async (req, res) => { 22 | let { count = 200, offset = 0 } = req.query; 23 | const radix = 10; 24 | count = parseInt(count, radix); 25 | offset = parseInt(offset, radix); 26 | const requst = { 27 | count: count, 28 | offset: offset, 29 | options: { 30 | include_optional_metadata: true, 31 | }, 32 | }; 33 | const response = await plaid.institutionsGet(request); 34 | const institutions = response.data.institutions; 35 | res.json(toArray(institutions)); 36 | }) 37 | ); 38 | 39 | /** 40 | * Fetches a single institution from the Plaid API. 41 | * 42 | * @param {string} instId The ins_id of the institution to be returned. 43 | * @returns {Object[]} an array containing a single institution. 44 | */ 45 | router.get( 46 | '/:instId', 47 | asyncWrapper(async (req, res) => { 48 | const { instId } = req.params; 49 | const request = { 50 | institution_id: instId, 51 | country_codes: ['US'], 52 | options: { 53 | include_optional_metadata: true, 54 | }, 55 | }; 56 | try { 57 | const response = await plaid.institutionsGetById(request); 58 | const institution = response.data.institution; 59 | res.json(toArray(institution)); 60 | } catch (error) {} 61 | }) 62 | ); 63 | 64 | module.exports = router; 65 | -------------------------------------------------------------------------------- /server/routes/items.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines all routes for the Items route. 3 | */ 4 | 5 | const express = require('express'); 6 | const Boom = require('@hapi/boom'); 7 | const { 8 | retrieveItemById, 9 | retrieveItemByPlaidInstitutionId, 10 | retrieveAccountsByItemId, 11 | retrieveTransactionsByItemId, 12 | createItem, 13 | deleteItem, 14 | updateItemStatus, 15 | } = require('../db/queries'); 16 | const { asyncWrapper } = require('../middleware'); 17 | const plaid = require('../plaid'); 18 | const { 19 | sanitizeAccounts, 20 | sanitizeItems, 21 | sanitizeTransactions, 22 | isValidItemStatus, 23 | validItemStatuses, 24 | } = require('../util'); 25 | const updateTransactions = require('../update_transactions'); 26 | 27 | const router = express.Router(); 28 | 29 | /** 30 | * First exchanges a public token for a private token via the Plaid API 31 | * and then stores the newly created item in the DB. 32 | * 33 | * @param {string} publicToken public token returned from the onSuccess call back in Link. 34 | * @param {string} institutionId the Plaid institution ID of the new item. 35 | * @param {string} userId the Plaid user ID of the active user. 36 | */ 37 | router.post( 38 | '/', 39 | asyncWrapper(async (req, res) => { 40 | const { publicToken, institutionId, userId } = req.body; 41 | // prevent duplicate items for the same institution per user. 42 | const existingItem = await retrieveItemByPlaidInstitutionId( 43 | institutionId, 44 | userId 45 | ); 46 | if (existingItem) 47 | throw new Boom('You have already linked an item at this institution.', { 48 | statusCode: 409, 49 | }); 50 | 51 | // exchange the public token for a private access token and store with the item. 52 | const response = await plaid.itemPublicTokenExchange({ 53 | public_token: publicToken, 54 | }); 55 | const accessToken = response.data.access_token; 56 | const itemId = response.data.item_id; 57 | const newItem = await createItem( 58 | institutionId, 59 | accessToken, 60 | itemId, 61 | userId 62 | ); 63 | 64 | // Make an initial call to fetch transactions and enable SYNC_UPDATES_AVAILABLE webhook sending 65 | // for this item. 66 | updateTransactions(itemId).then(() => { 67 | // Notify frontend to reflect any transactions changes. 68 | req.io.emit('NEW_TRANSACTIONS_DATA', { itemId: newItem.id }); 69 | }); 70 | 71 | res.json(sanitizeItems(newItem)); 72 | }) 73 | ); 74 | 75 | /** 76 | * Retrieves a single item. 77 | * 78 | * @param {string} itemId the ID of the item. 79 | * @returns {Object[]} an array containing a single item. 80 | */ 81 | router.get( 82 | '/:itemId', 83 | asyncWrapper(async (req, res) => { 84 | const { itemId } = req.params; 85 | const item = await retrieveItemById(itemId); 86 | res.json(sanitizeItems(item)); 87 | }) 88 | ); 89 | 90 | /** 91 | * Updates a single item. 92 | * 93 | * @param {string} itemId the ID of the item. 94 | * @returns {Object[]} an array containing a single item. 95 | */ 96 | router.put( 97 | '/:itemId', 98 | asyncWrapper(async (req, res) => { 99 | const { itemId } = req.params; 100 | const { status } = req.body; 101 | 102 | if (status) { 103 | if (!isValidItemStatus(status)) { 104 | throw new Boom( 105 | 'Cannot set item status. Please use an accepted value.', 106 | { 107 | statusCode: 400, 108 | acceptedValues: [validItemStatuses.values()], 109 | } 110 | ); 111 | } 112 | await updateItemStatus(itemId, status); 113 | const item = await retrieveItemById(itemId); 114 | res.json(sanitizeItems(item)); 115 | } else { 116 | throw new Boom('You must provide updated item information.', { 117 | statusCode: 400, 118 | acceptedKeys: ['status'], 119 | }); 120 | } 121 | }) 122 | ); 123 | 124 | /** 125 | * Deletes a single item and related accounts and transactions. 126 | * Also removes the item from the Plaid API 127 | * access_token associated with the Item is no longer valid 128 | * https://plaid.com/docs/#remove-item-request 129 | * @param {string} itemId the ID of the item. 130 | * @returns status of 204 if successful 131 | */ 132 | router.delete( 133 | '/:itemId', 134 | asyncWrapper(async (req, res) => { 135 | const { itemId } = req.params; 136 | const { plaid_access_token: accessToken } = await retrieveItemById(itemId); 137 | /* eslint-disable camelcase */ 138 | try { 139 | const response = await plaid.itemRemove({ 140 | access_token: accessToken, 141 | }); 142 | const removed = response.data.removed; 143 | const status_code = response.data.status_code; 144 | } catch (error) { 145 | if (!removed) 146 | throw new Boom('Item could not be removed in the Plaid API.', { 147 | statusCode: status_code, 148 | }); 149 | } 150 | await deleteItem(itemId); 151 | 152 | res.sendStatus(204); 153 | }) 154 | ); 155 | 156 | /** 157 | * Retrieves all accounts associated with a single item. 158 | * 159 | * @param {string} itemId the ID of the item. 160 | * @returns {Object[]} an array of accounts. 161 | */ 162 | router.get( 163 | '/:itemId/accounts', 164 | asyncWrapper(async (req, res) => { 165 | const { itemId } = req.params; 166 | const accounts = await retrieveAccountsByItemId(itemId); 167 | res.json(sanitizeAccounts(accounts)); 168 | }) 169 | ); 170 | 171 | /** 172 | * Retrieves all transactions associated with a single item. 173 | * 174 | * @param {string} itemId the ID of the item. 175 | * @returns {Object[]} an array of transactions. 176 | */ 177 | router.get( 178 | '/:itemId/transactions', 179 | asyncWrapper(async (req, res) => { 180 | const { itemId } = req.params; 181 | const transactions = await retrieveTransactionsByItemId(itemId); 182 | res.json(sanitizeTransactions(transactions)); 183 | }) 184 | ); 185 | 186 | /** 187 | * -- This endpoint will only work in the sandbox enviornment -- 188 | * Forces an Item into an ITEM_LOGIN_REQUIRED (bad) error state. 189 | * An ITEM_LOGIN_REQUIRED webhook will be fired after a call to this endpoint. 190 | * https://plaid.com/docs/#managing-item-states 191 | * 192 | * @param {string} itemId the Plaid ID of the item. 193 | * @return {Object} the response from the Plaid API. 194 | */ 195 | router.post( 196 | '/sandbox/item/reset_login', 197 | asyncWrapper(async (req, res) => { 198 | const { itemId } = req.body; 199 | const { plaid_access_token: accessToken } = await retrieveItemById(itemId); 200 | const resetResponse = await plaid.sandboxItemResetLogin({ 201 | access_token: accessToken, 202 | }); 203 | res.json(resetResponse.data); 204 | }) 205 | ); 206 | 207 | module.exports = router; 208 | -------------------------------------------------------------------------------- /server/routes/linkEvents.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines all routes for Link Events. 3 | */ 4 | 5 | const express = require('express'); 6 | 7 | const { createLinkEvent } = require('../db/queries'); 8 | const { asyncWrapper } = require('../middleware'); 9 | 10 | const router = express.Router(); 11 | 12 | /** 13 | * Creates a new link event . 14 | * 15 | * @param {string} userId the ID of the user. 16 | * @param {string} type displayed as 'success' or 'exit' based on the callback. 17 | * @param {string} link_session_id the session ID created when connecting with link. 18 | * @param {string} request_id the request ID created only on error when connecting with link. 19 | * @param {string} error_type a broad categorization of the error. 20 | * @param {string} error_code a specific code that is a subset of the error_type. 21 | */ 22 | router.post( 23 | '/', 24 | asyncWrapper(async (req, res) => { 25 | await createLinkEvent(req.body); 26 | res.sendStatus(200); 27 | }) 28 | ); 29 | 30 | module.exports = router; 31 | -------------------------------------------------------------------------------- /server/routes/linkTokens.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines the route for link token creation. 3 | */ 4 | 5 | const { asyncWrapper } = require('../middleware'); 6 | 7 | const express = require('express'); 8 | const plaid = require('../plaid'); 9 | const fetch = require('node-fetch'); 10 | const { retrieveItemById } = require('../db/queries'); 11 | const { 12 | PLAID_SANDBOX_REDIRECT_URI, 13 | PLAID_PRODUCTION_REDIRECT_URI, 14 | PLAID_ENV, 15 | } = process.env; 16 | 17 | const redirect_uri = 18 | PLAID_ENV == 'sandbox' 19 | ? PLAID_SANDBOX_REDIRECT_URI 20 | : PLAID_PRODUCTION_REDIRECT_URI; 21 | const router = express.Router(); 22 | 23 | router.post( 24 | '/', 25 | asyncWrapper(async (req, res) => { 26 | try { 27 | const { userId, itemId } = req.body; 28 | let accessToken = null; 29 | let products = ['transactions']; // must include transactions in order to receive transactions webhooks 30 | if (itemId != null) { 31 | // for the link update mode, include access token and an empty products array 32 | const itemIdResponse = await retrieveItemById(itemId); 33 | accessToken = itemIdResponse.plaid_access_token; 34 | products = []; 35 | } 36 | const response = await fetch('http://ngrok:4040/api/tunnels'); 37 | const { tunnels } = await response.json(); 38 | const httpsTunnel = tunnels.find(t => t.proto === 'https'); 39 | const linkTokenParams = { 40 | user: { 41 | // This should correspond to a unique id for the current user. 42 | client_user_id: 'uniqueId' + userId, 43 | }, 44 | client_name: 'Pattern', 45 | products, 46 | country_codes: ['US'], 47 | language: 'en', 48 | webhook: httpsTunnel.public_url + '/services/webhook', 49 | access_token: accessToken, 50 | }; 51 | // If user has entered a redirect uri in the .env file 52 | if (redirect_uri.indexOf('http') === 0) { 53 | linkTokenParams.redirect_uri = redirect_uri; 54 | } 55 | const createResponse = await plaid.linkTokenCreate(linkTokenParams); 56 | res.json(createResponse.data); 57 | } catch (err) { 58 | console.log('error while fetching client token', err.response.data); 59 | return res.json(err.response.data); 60 | } 61 | }) 62 | ); 63 | 64 | module.exports = router; 65 | -------------------------------------------------------------------------------- /server/routes/services.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines all routes for the Services route. 3 | */ 4 | 5 | const express = require('express'); 6 | const fetch = require('node-fetch'); 7 | 8 | const router = express.Router(); 9 | 10 | const { asyncWrapper } = require('../middleware'); 11 | const { 12 | handleTransactionsWebhook, 13 | handleItemWebhook, 14 | unhandledWebhook, 15 | } = require('../webhookHandlers'); 16 | 17 | /** 18 | * Returns the URL of the current public endpoint generated by ngrok. 19 | * 20 | * @returns {Object} the public endpoint currently active. 21 | */ 22 | router.get( 23 | '/ngrok', 24 | asyncWrapper(async (req, res) => { 25 | const response = await fetch('http://ngrok:4040/api/tunnels'); 26 | const { tunnels } = await response.json(); 27 | const httpTunnel = tunnels.find(t => t.proto === 'http'); 28 | res.json({ url: httpTunnel.public_url }); 29 | }) 30 | ); 31 | 32 | /** 33 | * Handles incoming webhooks from Plaid. 34 | * https://plaid.com/docs/#webhooks 35 | */ 36 | router.post( 37 | '/webhook', 38 | asyncWrapper(async (req, res) => { 39 | const { webhook_type: webhookType } = req.body; 40 | const { io } = req; 41 | const type = webhookType.toLowerCase(); 42 | // There are five types of webhooks: AUTH, TRANSACTIONS, ITEM, INCOME, and ASSETS. 43 | // @TODO implement handling for remaining webhook types. 44 | const webhookHandlerMap = { 45 | transactions: handleTransactionsWebhook, 46 | item: handleItemWebhook, 47 | }; 48 | const webhookHandler = webhookHandlerMap[type] || unhandledWebhook; 49 | webhookHandler(req.body, io); 50 | res.json({ status: 'ok' }); 51 | }) 52 | ); 53 | 54 | module.exports = router; 55 | -------------------------------------------------------------------------------- /server/routes/sessions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines all routes for the Users route. 3 | */ 4 | 5 | const express = require('express'); 6 | const { retrieveUserByUsername } = require('../db/queries'); 7 | const { asyncWrapper } = require('../middleware'); 8 | const { sanitizeUsers } = require('../util'); 9 | 10 | const router = express.Router(); 11 | 12 | /** 13 | * Retrieves user information for a single user. 14 | * 15 | * @param {string} username the name of the user. 16 | * @returns {Object[]} an array containing a single user. 17 | */ 18 | router.post( 19 | '/', 20 | asyncWrapper(async (req, res) => { 21 | const { username } = req.body; 22 | const user = await retrieveUserByUsername(username); 23 | if (user != null) { 24 | res.json(sanitizeUsers(user)); 25 | } else { 26 | res.json(null); 27 | } 28 | }) 29 | ); 30 | 31 | module.exports = router; 32 | -------------------------------------------------------------------------------- /server/routes/unhandled.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines the unhandled route handler. 3 | */ 4 | 5 | const express = require('express'); 6 | const Boom = require('@hapi/boom'); 7 | 8 | const router = express.Router(); 9 | 10 | /** 11 | * Throws a 404 not found error for all requests. 12 | */ 13 | router.get('*', (req, res) => { 14 | throw new Boom('not found', { statusCode: 404 }); 15 | }); 16 | 17 | module.exports = router; 18 | -------------------------------------------------------------------------------- /server/routes/users.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines all routes for the Users route. 3 | */ 4 | 5 | const express = require('express'); 6 | const Boom = require('@hapi/boom'); 7 | const { 8 | retrieveUsers, 9 | retrieveUserByUsername, 10 | retrieveAccountsByUserId, 11 | createUser, 12 | deleteUsers, 13 | retrieveItemsByUser, 14 | retrieveTransactionsByUserId, 15 | retrieveUserById, 16 | } = require('../db/queries'); 17 | const { asyncWrapper } = require('../middleware'); 18 | const { 19 | sanitizeAccounts, 20 | sanitizeItems, 21 | sanitizeUsers, 22 | sanitizeTransactions, 23 | } = require('../util'); 24 | 25 | const router = express.Router(); 26 | 27 | const plaid = require('../plaid'); 28 | 29 | /** 30 | * Retrieves all users. 31 | * 32 | * @returns {Object[]} an array of users. 33 | */ 34 | router.get( 35 | '/', 36 | asyncWrapper(async (req, res) => { 37 | const users = await retrieveUsers(); 38 | res.json(sanitizeUsers(users)); 39 | }) 40 | ); 41 | 42 | /** 43 | * Creates a new user (unless the username is already taken). 44 | * 45 | * @TODO make this return an array for consistency. 46 | * 47 | * @param {string} username the username of the new user. 48 | * @returns {Object[]} an array containing the new user. 49 | */ 50 | router.post( 51 | '/', 52 | asyncWrapper(async (req, res) => { 53 | const { username } = req.body; 54 | const usernameExists = await retrieveUserByUsername(username); 55 | // prevent duplicates 56 | if (usernameExists) 57 | throw new Boom('Username already exists', { statusCode: 409 }); 58 | const newUser = await createUser(username); 59 | res.json(sanitizeUsers(newUser)); 60 | }) 61 | ); 62 | 63 | /** 64 | * Retrieves user information for a single user. 65 | * 66 | * @param {string} userId the ID of the user. 67 | * @returns {Object[]} an array containing a single user. 68 | */ 69 | router.get( 70 | '/:userId', 71 | asyncWrapper(async (req, res) => { 72 | const { userId } = req.params; 73 | const user = await retrieveUserById(userId); 74 | res.json(sanitizeUsers(user)); 75 | }) 76 | ); 77 | 78 | /** 79 | * Retrieves all items associated with a single user. 80 | * 81 | * @param {string} userId the ID of the user. 82 | * @returns {Object[]} an array of items. 83 | */ 84 | router.get( 85 | '/:userId/items', 86 | asyncWrapper(async (req, res) => { 87 | const { userId } = req.params; 88 | const items = await retrieveItemsByUser(userId); 89 | res.json(sanitizeItems(items)); 90 | }) 91 | ); 92 | 93 | /** 94 | * Retrieves all accounts associated with a single user. 95 | * 96 | * @param {string} userId the ID of the user. 97 | * @returns {Object[]} an array of accounts. 98 | */ 99 | router.get( 100 | '/:userId/accounts', 101 | asyncWrapper(async (req, res) => { 102 | const { userId } = req.params; 103 | const accounts = await retrieveAccountsByUserId(userId); 104 | res.json(sanitizeAccounts(accounts)); 105 | }) 106 | ); 107 | 108 | /** 109 | * Retrieves all transactions associated with a single user. 110 | * 111 | * @param {string} userId the ID of the user. 112 | * @returns {Object[]} an array of transactions 113 | */ 114 | router.get( 115 | '/:userId/transactions', 116 | asyncWrapper(async (req, res) => { 117 | const { userId } = req.params; 118 | const transactions = await retrieveTransactionsByUserId(userId); 119 | res.json(sanitizeTransactions(transactions)); 120 | }) 121 | ); 122 | 123 | /** 124 | * Deletes a user and its related items 125 | * 126 | * @param {string} userId the ID of the user. 127 | */ 128 | router.delete( 129 | '/:userId', 130 | asyncWrapper(async (req, res) => { 131 | const { userId } = req.params; 132 | 133 | // removes all items from Plaid services associated with the user. Once removed, the access_token 134 | // associated with an Item is no longer valid and cannot be used to 135 | // access any data that was associated with the Item. 136 | 137 | // @TODO wrap promise in a try catch block once proper error handling introduced 138 | const items = await retrieveItemsByUser(userId); 139 | await Promise.all( 140 | items.map(({ plaid_access_token: token }) => 141 | plaid.itemRemove({ access_token: token }) 142 | ) 143 | ); 144 | 145 | // delete from the db 146 | await deleteUsers(userId); 147 | res.sendStatus(204); 148 | }) 149 | ); 150 | 151 | module.exports = router; 152 | -------------------------------------------------------------------------------- /server/update_transactions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines helpers for updating transactions on an item 3 | */ 4 | 5 | const plaid = require('./plaid'); 6 | const { 7 | retrieveItemByPlaidItemId, 8 | createAccounts, 9 | createOrUpdateTransactions, 10 | deleteTransactions, 11 | updateItemTransactionsCursor, 12 | } = require('./db/queries'); 13 | 14 | /** 15 | * Fetches transactions from the Plaid API for a given item. 16 | * 17 | * @param {string} plaidItemId the Plaid ID for the item. 18 | * @returns {Object{}} an object containing transactions and a cursor. 19 | */ 20 | const fetchTransactionUpdates = async (plaidItemId) => { 21 | // the transactions endpoint is paginated, so we may need to hit it multiple times to 22 | // retrieve all available transactions. 23 | 24 | // get the access token based on the plaid item id 25 | const { 26 | plaid_access_token: accessToken, 27 | transactions_cursor: lastCursor, 28 | } = await retrieveItemByPlaidItemId( 29 | plaidItemId 30 | ); 31 | 32 | let cursor = lastCursor; 33 | 34 | // New transaction updates since "cursor" 35 | let added = []; 36 | let modified = []; 37 | // Removed transaction ids 38 | let removed = []; 39 | let hasMore = true; 40 | 41 | const batchSize = 100; 42 | try { 43 | // Iterate through each page of new transaction updates for item 44 | /* eslint-disable no-await-in-loop */ 45 | while (hasMore) { 46 | const request = { 47 | access_token: accessToken, 48 | cursor: cursor, 49 | count: batchSize, 50 | }; 51 | const response = await plaid.transactionsSync(request) 52 | const data = response.data; 53 | // Add this page of results 54 | added = added.concat(data.added); 55 | modified = modified.concat(data.modified); 56 | removed = removed.concat(data.removed); 57 | hasMore = data.has_more; 58 | // Update cursor to the next cursor 59 | cursor = data.next_cursor; 60 | } 61 | } catch (err) { 62 | console.error(`Error fetching transactions: ${err.message}`); 63 | cursor = lastCursor; 64 | } 65 | return { added, modified, removed, cursor, accessToken }; 66 | }; 67 | 68 | /** 69 | * Handles the fetching and storing of new, modified, or removed transactions 70 | * 71 | * @param {string} plaidItemId the Plaid ID for the item. 72 | */ 73 | const updateTransactions = async (plaidItemId) => { 74 | // Fetch new transactions from plaid api. 75 | const { 76 | added, 77 | modified, 78 | removed, 79 | cursor, 80 | accessToken 81 | } = await fetchTransactionUpdates(plaidItemId); 82 | 83 | 84 | const request = { 85 | access_token: accessToken, 86 | }; 87 | 88 | const {data: {accounts}} = await plaid.accountsGet(request); 89 | 90 | // Update the DB. 91 | await createAccounts(plaidItemId, accounts); 92 | await createOrUpdateTransactions(added.concat(modified)); 93 | await deleteTransactions(removed); 94 | await updateItemTransactionsCursor(plaidItemId, cursor); 95 | return { 96 | addedCount: added.length, 97 | modifiedCount: modified.length, 98 | removedCount: removed.length, 99 | }; 100 | }; 101 | 102 | module.exports = updateTransactions; 103 | -------------------------------------------------------------------------------- /server/util.js: -------------------------------------------------------------------------------- 1 | const isArray = require('lodash/isArray'); 2 | const pick = require('lodash/pick'); 3 | 4 | /** 5 | * Wraps input in an array if needed. 6 | * 7 | * @param {*} input the data to be wrapped in array if needed. 8 | * @returns {*[]} an array based on the input. 9 | */ 10 | const toArray = input => (isArray(input) ? [...input] : [input]); 11 | 12 | /** 13 | * Returns an array of objects that have only the given keys present. 14 | * 15 | * @param {(Object|Object[])} input a single object or an array of objects. 16 | * @param {string[]} keysToKeep the keys to keep in the sanitized objects. 17 | */ 18 | const sanitizeWith = (input, keysToKeep) => 19 | toArray(input).map(obj => pick(obj, keysToKeep)); 20 | 21 | /** 22 | * Returns an array of sanitized accounts. 23 | * 24 | * @param {(Object|Object[])} accounts a single account or an array of accounts. 25 | */ 26 | const sanitizeAccounts = accounts => 27 | sanitizeWith(accounts, [ 28 | 'id', 29 | 'item_id', 30 | 'user_id', 31 | 'name', 32 | 'mask', 33 | 'official_name', 34 | 'current_balance', 35 | 'available_balance', 36 | 'iso_currency_code', 37 | 'unofficial_currency_code', 38 | 'type', 39 | 'subtype', 40 | 'created_at', 41 | 'updated_at', 42 | ]); 43 | 44 | /** 45 | * Returns an array of sanitized items. 46 | * 47 | * @param {(Object|Object[])} items a single item or an array of items. 48 | */ 49 | const sanitizeItems = items => 50 | sanitizeWith(items, [ 51 | 'id', 52 | 'user_id', 53 | 'plaid_institution_id', 54 | 'status', 55 | 'created_at', 56 | 'updated_at', 57 | ]); 58 | 59 | /** 60 | * Returns an array of sanitized users. 61 | * 62 | * @param {(Object|Object[])} users a single user or an array of users. 63 | */ 64 | const sanitizeUsers = users => 65 | sanitizeWith(users, ['id', 'username', 'created_at', 'updated_at']); 66 | 67 | /** 68 | * Returns an array of transactions 69 | * 70 | * @param {(Object|Object[])} transactions a single transaction of an array of transactions. 71 | */ 72 | const sanitizeTransactions = transactions => 73 | sanitizeWith(transactions, [ 74 | 'id', 75 | 'account_id', 76 | 'item_id', 77 | 'user_id', 78 | 'name', 79 | 'type', 80 | 'date', 81 | 'category', 82 | 'amount', 83 | 'created_at', 84 | 'updated_at', 85 | ]); 86 | 87 | const validItemStatuses = new Set(['good', 'bad']); 88 | const isValidItemStatus = status => validItemStatuses.has(status); 89 | 90 | module.exports = { 91 | toArray, 92 | sanitizeAccounts, 93 | sanitizeItems, 94 | sanitizeUsers, 95 | sanitizeTransactions, 96 | validItemStatuses, 97 | isValidItemStatus, 98 | }; 99 | -------------------------------------------------------------------------------- /server/webhookHandlers/handleItemWebhook.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines the handler for Item webhooks. 3 | * https://plaid.com/docs/#item-webhooks 4 | */ 5 | 6 | const { 7 | updateItemStatus, 8 | retrieveItemByPlaidItemId, 9 | } = require('../db/queries'); 10 | 11 | /** 12 | * Handles Item errors received from item webhooks. When an error is received 13 | * different operations are needed to update an item based on the the error_code 14 | * that is encountered. 15 | * 16 | * @param {string} plaidItemId the Plaid ID of an item. 17 | * @param {Object} error the error received from the webhook. 18 | */ 19 | const itemErrorHandler = async (plaidItemId, error) => { 20 | const { error_code: errorCode } = error; 21 | switch (errorCode) { 22 | case 'ITEM_LOGIN_REQUIRED': { 23 | const { id: itemId } = await retrieveItemByPlaidItemId(plaidItemId); 24 | await updateItemStatus(itemId, 'bad'); 25 | break; 26 | } 27 | default: 28 | console.log( 29 | `WEBHOOK: ITEMS: Plaid item id ${plaidItemId}: unhandled ITEM error` 30 | ); 31 | } 32 | }; 33 | 34 | /** 35 | * Handles all Item webhook events. 36 | * 37 | * @param {Object} requestBody the request body of an incoming webhook event. 38 | * @param {Object} io a socket.io server instance. 39 | */ 40 | const itemsHandler = async (requestBody, io) => { 41 | const { 42 | webhook_code: webhookCode, 43 | item_id: plaidItemId, 44 | error, 45 | } = requestBody; 46 | 47 | const serverLogAndEmitSocket = (additionalInfo, itemId, errorCode) => { 48 | console.log( 49 | `WEBHOOK: ITEMS: ${webhookCode}: Plaid item id ${plaidItemId}: ${additionalInfo}` 50 | ); 51 | // use websocket to notify the client that a webhook has been received and handled 52 | if (webhookCode) io.emit(webhookCode, { itemId, errorCode }); 53 | }; 54 | 55 | switch (webhookCode) { 56 | case 'WEBHOOK_UPDATE_ACKNOWLEDGED': 57 | serverLogAndEmitSocket('is updated', plaidItemId, error); 58 | break; 59 | case 'ERROR': { 60 | itemErrorHandler(plaidItemId, error); 61 | const { id: itemId } = await retrieveItemByPlaidItemId(plaidItemId); 62 | serverLogAndEmitSocket( 63 | `ERROR: ${error.error_code}: ${error.error_message}`, 64 | itemId, 65 | error.error_code 66 | ); 67 | break; 68 | } 69 | case 'PENDING_EXPIRATION': 70 | case 'PENDING_DISCONNECT': { 71 | const { id: itemId } = await retrieveItemByPlaidItemId(plaidItemId); 72 | await updateItemStatus(itemId, 'bad'); 73 | serverLogAndEmitSocket( 74 | `user needs to re-enter login credentials`, 75 | itemId, 76 | error 77 | ); 78 | break; 79 | } 80 | default: 81 | serverLogAndEmitSocket( 82 | 'unhandled webhook type received.', 83 | plaidItemId, 84 | error 85 | ); 86 | } 87 | }; 88 | 89 | module.exports = itemsHandler; 90 | -------------------------------------------------------------------------------- /server/webhookHandlers/handleTransactionsWebhook.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines the handler for Transactions webhooks. 3 | * https://plaid.com/docs/#transactions-webhooks 4 | */ 5 | 6 | const { 7 | retrieveItemByPlaidItemId, 8 | } = require('../db/queries'); 9 | 10 | const updateTransactions = require('../update_transactions'); 11 | 12 | /** 13 | * Handles all transaction webhook events. The transaction webhook notifies 14 | * you that a single item has new transactions available. 15 | * 16 | * @param {Object} requestBody the request body of an incoming webhook event 17 | * @param {Object} io a socket.io server instance. 18 | */ 19 | const handleTransactionsWebhook = async (requestBody, io) => { 20 | const { 21 | webhook_code: webhookCode, 22 | item_id: plaidItemId, 23 | } = requestBody; 24 | 25 | const serverLogAndEmitSocket = (additionalInfo, itemId) => { 26 | console.log( 27 | `WEBHOOK: TRANSACTIONS: ${webhookCode}: Plaid_item_id ${plaidItemId}: ${additionalInfo}` 28 | ); 29 | // use websocket to notify the client that a webhook has been received and handled 30 | if (webhookCode) io.emit(webhookCode, { itemId }); 31 | }; 32 | 33 | switch (webhookCode) { 34 | case 'SYNC_UPDATES_AVAILABLE': { 35 | // Fired when new transactions data becomes available. 36 | const { 37 | addedCount, 38 | modifiedCount, 39 | removedCount, 40 | } = await updateTransactions(plaidItemId); 41 | const { id: itemId } = await retrieveItemByPlaidItemId(plaidItemId); 42 | serverLogAndEmitSocket(`Transactions: ${addedCount} added, ${modifiedCount} modified, ${removedCount} removed`, itemId); 43 | break; 44 | } 45 | case 'DEFAULT_UPDATE': 46 | case 'INITIAL_UPDATE': 47 | case 'HISTORICAL_UPDATE': 48 | /* ignore - not needed if using sync endpoint + webhook */ 49 | break; 50 | default: 51 | serverLogAndEmitSocket(`unhandled webhook type received.`, plaidItemId); 52 | } 53 | }; 54 | 55 | module.exports = handleTransactionsWebhook; 56 | -------------------------------------------------------------------------------- /server/webhookHandlers/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines the handlers for various types of webhooks. 3 | */ 4 | 5 | const handleItemWebhook = require('./handleItemWebhook'); 6 | const handleTransactionsWebhook = require('./handleTransactionsWebhook'); 7 | const unhandledWebhook = require('./unhandledWebhook'); 8 | 9 | module.exports = { 10 | handleItemWebhook, 11 | handleTransactionsWebhook, 12 | unhandledWebhook, 13 | }; 14 | -------------------------------------------------------------------------------- /server/webhookHandlers/unhandledWebhook.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Defines the handler for unhandled webhook types. 3 | */ 4 | 5 | /** 6 | * Handles all unhandled/not yet implemented webhook events. 7 | * 8 | * @param {Object} requestBody the request body of an incoming webhook event 9 | */ 10 | const unhandledWebhook = async requestBody => { 11 | const { 12 | webhook_type: webhookType, 13 | webhook_code: webhookCode, 14 | item_id: plaidItemId, 15 | } = requestBody; 16 | console.log( 17 | `UNHANDLED ${webhookType} WEBHOOK: ${webhookCode}: Plaid item id ${plaidItemId}: unhandled webhook type received.` 18 | ); 19 | }; 20 | 21 | module.exports = unhandledWebhook; 22 | -------------------------------------------------------------------------------- /version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | VALID_SEMVER='[0-9]*\.[0-9]*\.[0-9]*' 3 | 4 | # get current version 5 | current_version=$(grep -m 1 -o ${VALID_SEMVER} docker-compose.yml) 6 | echo "Current version: ${current_version}" 7 | 8 | # get new version from user input 9 | read -p 'Enter the new version (e.g. 1.0.1): ' version 10 | 11 | # confirm that version is in the correct format 12 | exp='^'${VALID_SEMVER}'$' 13 | if [[ ! $version =~ $exp ]]; then 14 | echo "Error: ${version} is not formatted correctly." 15 | exit 1 16 | fi 17 | echo "Updating files..." 18 | 19 | # update docker-compose.yml 20 | echo -n "docker-compose.yml ... " 21 | exp='s/'${VALID_SEMVER}'/'${version}'/g' 22 | perl -i -pe $exp docker-compose.yml 23 | echo "done" 24 | 25 | # update client package files 26 | echo -n "client/package*.json ... " 27 | cd client 28 | npm version $version &>/dev/null 29 | cd .. 30 | echo "done" 31 | 32 | # update server package files 33 | echo -n "server/package*.json ... " 34 | cd server 35 | npm version $version &>/dev/null 36 | cd .. 37 | echo "done" 38 | 39 | # done 40 | echo "All files updated to ${version}" 41 | -------------------------------------------------------------------------------- /wait-for-client.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # wait-for-client.sh 3 | 4 | # wait for a response from the client 5 | echo -n "Waiting for the client to finish initializing..." 6 | while [ "$(curl -s -o /dev/null -w "%{http_code}" -m 1 localhost:3001)" != "200" ] 7 | do 8 | sleep 1 9 | echo -n "." 10 | done 11 | 12 | # print message 13 | cat <