diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index b8c9f1e8..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,29 +0,0 @@ -version: 2 - -jobs: - build: - docker: - - image: circleci/node:10 - working_directory: ~/repo - steps: - - checkout - - run: - name: Install Dependencies - command: yarn install - - run: - name: Mintable - command: yarn mintable - -workflows: - version: 2 - daily: - triggers: - - schedule: - # 10AM UTC = 6AM EST daily - cron: "0 10 * * *" - filters: - branches: - only: - - master - jobs: - - build diff --git a/.github/workflows/fetch.yml b/.github/workflows/fetch.yml new file mode 100644 index 00000000..35f450b2 --- /dev/null +++ b/.github/workflows/fetch.yml @@ -0,0 +1,33 @@ +name: Fetch + +on: + push: + branches: [ master, release/2.0.0 ] + + pull_request: + branches: [ master, release/2.0.0 ] + + # schedule: + # - cron: '0 * * * *' + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [12.x, 14.x] + + env: + MINTABLE_CONFIG: ${{ secrets.MINTABLE_CONFIG }} + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm run build + - run: node ./lib/scripts/cli.js fetch --ci diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..9f1b1bd5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Test + +on: + push: + branches: [ master, release/2.0.0 ] + pull_request: + branches: [ master, release/2.0.0 ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [12.x, 14.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/.gitignore b/.gitignore index e3088650..fa4d5e53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,16 @@ .DS_Store .env -.next -*/.next + +build/ +lib/ + mintable.config.json mintable.sandbox.json -node_modules +mintable.jsonc + +node_modules/ package-lock.json yarn-error.log yarn.lock +.next +*/.next diff --git a/.prettierrc b/.prettierrc index 1071fa63..665b34a4 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { - "tabWidth": 2, + "tabWidth": 4, "semi": false, "singleQuote": true, "jsxSingleQuote": true, diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d056ef13..00000000 --- a/.travis.yml +++ /dev/null @@ -1,5 +0,0 @@ -language: node_js -node_js: - - "node" -script: - - "yarn mintable" \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 22b0a910..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Launch Program", - "program": "${workspaceFolder}/src/scripts/setup.js" - } - ] -} \ No newline at end of file diff --git a/README.md b/README.md index 795a7f1f..c31835e7 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,76 @@ -

Mintable

Roll your own ad-free Mint clone for managing personal finances using publicly available APIs.


+

Mintable

+

Mintable

-[![](https://img.shields.io/travis/com/kevinschaich/mintable/master.svg)](https://travis-ci.com/kevinschaich/mintable) -[![](https://img.shields.io/github/release/kevinschaich/mintable.svg)](https://github.com/kevinschaich/mintable/releases) -[![](https://img.shields.io/github/license/kevinschaich/mintable.svg)](https://github.com/kevinschaich/mintable/blob/master/LICENSE) -[![](https://img.shields.io/github/contributors/kevinschaich/mintable.svg)](https://github.com/kevinschaich/mintable/graphs/contributors) -[![](https://img.shields.io/github/issues/kevinschaich/mintable.svg)](https://github.com/kevinschaich/mintable/issues) -[![](https://img.shields.io/github/issues-pr/kevinschaich/mintable.svg)](https://github.com/kevinschaich/mintable/pulls) -[![](https://img.shields.io/reddit/subreddit-subscribers/Mintable?style=social)](https://reddit.com/r/Mintable) +

Automate your personal finances – for free, with no ads, and no data collection.

-## Quickstart +
-**Prerequisites:** `node` (tested `v11.6.0`), `yarn` (tested `v1.10.0`) +Mintable helps you: -1. If you plan on using Plaid to fetch account data, [sign up](https://dashboard.plaid.com/signup) for an account and [apply for the development plan](https://plaid.com/pricing/). It usually takes them 1-2 business days to approve this request. -2. Link your accounts and a spreadsheet to Mintable. Run these commands to walk through the setup: +- Keep track of your account balances +- Aggregate transactions from all your banking institutions, including checking accounts, savings accounts, and credit cards +- Analyze and budget your spending using a spreadsheet and formulas -```bash -git clone https://github.com/kevinschaich/mintable.git -cd mintable -yarn -yarn setup -``` +![](./docs/img/mintable.png) -3. After completing the setup, run the following at any time to populate updated data into your spreadsheet: +
-``` -yarn mintable -``` +[![](https://img.shields.io/travis/com/kevinschaich/mintable/master.svg)](https://travis-ci.com/kevinschaich/mintable) +[![](https://img.shields.io/github/release/kevinschaich/mintable.svg)](https://github.com/kevinschaich/mintable/releases) +[![](https://img.shields.io/github/license/kevinschaich/mintable.svg)](https://github.com/kevinschaich/mintable/blob/master/LICENSE) +[![](https://img.shields.io/github/issues/kevinschaich/mintable.svg)](https://github.com/kevinschaich/mintable/issues) +[![](https://img.shields.io/github/issues-pr/kevinschaich/mintable.svg)](https://github.com/kevinschaich/mintable/pulls) +[![](https://img.shields.io/reddit/subreddit-subscribers/Mintable?style=social)](https://reddit.com/r/Mintable) -> **Note**: If you started using Mintable before `v1.0.0`, you can run `yarn migrate` to migrate to the new web-based configuration framework. +--- -## Overview +## Quickstart -![Mintable](./src/static/mintable.png) +1. Sign up for [Plaid's Free Plan](https://plaid.com/pricing/). +2. Install Mintable: -Mintable simplifies managing your finances, for free, without ads, and without tracking your information. Here's how it works: + ```bash + npm install -g mintable + mintable setup + ``` -1. You connect your accounts and a spreadsheet to Mintable. -1. Mintable integrates with financial institutions to automatically populate transactions in your spreadsheet. -1. You can add whatever formulas, charts, or calculations you want (just like a normal spreadsheet). We also have templates to get you started. +3. Update your account balances/transactions: -## Features + ``` + mintable fetch + ``` -- Locally hosted, open-source, 100% free, ad-free, no personal data tracking, no data stored by Mintable on central servers -- Integrates with your financial institutions for fully-automated spreadsheet updates -- Web based setup wizard and configuration framework: +> **Note:** If you're already a version `1.x.x` user, you can [migrate your existing configuration to version `2.x.x`](./docs/README.md#migrating-from-v1xx). -![Setup Wizard](./src/static/setup.png) +## Documentation -You can see a full list of options in the **[Config Docs](./docs/CONFIG.md)**. +Check out the full documentation [in the `./docs` folder](./docs/README.md). ## FAQs -**It's not working / I'm having trouble / I need help** +**WTF is 'Mintable'?!** -- [File an issue](https://github.com/kevinschaich/mintable/issues) or reach out on our [Reddit community](https://www.reddit.com/r/Mintable/). +> **min·ta·ble**: _noun._ +> 1. An open-source tool to automate your personal finances – for free, with no ads, and no data collection. Derived from *mint* (the [wildly popular personal finance app from Intuit](https://www.mint.com/)) + *table* (a spreadsheet). -**How is this different from [build-your-own-mint](https://github.com/yyx990803/build-your-own-mint)?** +**Do I have to use Plaid?** -- **[build-your-own-mint](https://github.com/yyx990803/build-your-own-mint)** is a set of scripts which solely facilitates the integration between Plaid and Google Sheets. It makes no assumptions about what you want your spreadsheet to look like, and you have to define your own logic to map transactions to spreadsheet updates. -- **[Mintable](#)** is and end-to-end system that works out of the box. It comes with a setup wizard, a web-based configuration server, [pluggable providers](./docs/PROVIDERS.md) (you're not limited to just Plaid & Google Sheets), and a spreadsheet template. +Nope. You can [import transactions from a CSV bank statement](./docs/README.md#manually--on-your-local-machine--via-csv-bank-statements) exclusively on your local machine. We also have [templates](./docs/templates) to get you started. -**Do I have to give my data to Plaid and Google? Are there any completely self-hosted alternatives I can use?** +**Do I have to use Google Sheets?** -- It's [pluggable](./docs/PROVIDERS.md)! Plaid & Google Sheets are working right now – contributions are welcome for [other providers](./docs/PROVIDERS.md)! +Nope. You can [export your account balances & transactions to a CSV file](./docs/README.md#on-your-local-machine--via-csv-files) exclusively on your local machine. **Do I have to manually run this every time I want new transactions in my spreadsheet?** -- You can **[Automate Updates with a CI Provider](./docs/CONFIG.md#automate-updates-with-a-ci-provider)** to get free, automated updates! +Nope. You can automate it for free using [BitBar](./docs/README.md#automatically-in-your-macs-menu-bar--via-bitbar), [`cron`](./docs/README.md#automatically-in-your-local-machines-terminal--via-cron), or [GitHub Actions](./docs/README#automatically-in-the-cloud--via-github-actions). + +**It's not working!** + +- [File an issue](https://github.com/kevinschaich/mintable/issues) or [![](https://img.shields.io/reddit/subreddit-subscribers/Mintable?style=social)](https://reddit.com/r/Mintable). -## Credits +## Alternatives -Mintable initially started as a fork of [Evan You](https://github.com/yyx990803)'s [build-your-own-mint](https://github.com/yyx990803/build-your-own-mint). +- [**Money in Excel**](https://www.microsoft.com/en-us/microsoft-365/blog/2020/06/15/introducing-money-excel-easier-manage-finances/): Recently announced partnership between Microsoft/Plaid. Requires a Microsoft 365 subscription ($70+/year). +- [**Mint**](https://www.mint.com/): Owned by Intuit (TurboTax). Apps for iOS/Android/Web. +- [**build-your-own-mint**](https://github.com/yyx990803/build-your-own-mint): Some assembly required. More flexible. diff --git a/docs/CONFIG.md b/docs/CONFIG.md deleted file mode 100644 index 5c0e5a21..00000000 --- a/docs/CONFIG.md +++ /dev/null @@ -1,267 +0,0 @@ -# Configuration - -All configurations below can be made using the web configuration framework or by editing `mintable.config.json`. - -`mintable.config.json` is the secret sauce – it contains all of your private tokens and is never sent to third-party servers. This file is ignored by Git – keep a backup somewhere safe. - -> **Pro Tip:** You can use Dropbox or another trusted service to sync `mintable.config.json` across your machines. Run `ln -s /mintable.config.json .` from the repo root to symlink Mintable to the cloud version. - -#### Table of Contents - -- [General configuration](#general-configuration) -- [Fetching Balances](#fetching-balances) -- [Fetching Transactions](#fetching-transactions) -- [Plaid](#plaid) -- [Google Sheets](#google-sheets) - -## General configuration - -#### Debug Mode - -`DEBUG` mode logs the output of each API call and function to the console. - -**Default:** - -```javascript -"DEBUG": undefined // If unspecified, defaults to false -``` - -If you want to enable debug mode, you can add the following line to your `mintable.config.json` file: - -```javascript -"DEBUG": true -``` - -#### Host - -`HOST` specifies the host for Mintable's setup server. - -**Default:** - -```javascript -"HOST": "localhost" -``` - -For example, if you want to run Mintable on a custom server and listen on `0.0.0.0`, you could add the following line to your `mintable.config.json` file: - -```javascript -"HOST": "0.0.0.0" -``` - -#### Port - -`PORT` specifies the port for Mintable's setup server. - -**Default:** - -```javascript -"PORT": 3000 -``` - -For example, if you already have an application running on port `3000` and instead want to use port `8080`, you could add the following line to your `mintable.config.json` file: - -```javascript -"PORT": "8080" -``` - -#### Account Provider - -`ACCOUNT_PROVIDER` specifies which service to use to fetch transactions. - -**Default:** - -```javascript -"ACCOUNT_PROVIDER": "plaid" -``` - -#### Spreadsheet Provider - -`SHEET_PROVIDER` specifies which service to use to automate spreadsheet updates. - -**Default:** - -```javascript -"SHEET_PROVIDER": "sheets" // "sheets" = Google Sheets -``` - -#### Automate Updates with a CI Provider - -This repo includes config files for both [CircleCI](https://circleci.com/) and [Travis CI](https://travis-ci.com) to run builds automatically. - -Most CI providers allow you to set **environment variables** to configure sensitive information (like the stuff in `mintable.config.json`). We've included a handy script to get that set up: - -``` -yarn export -``` - -Run this command and paste the result into an environment variable called `MINTABLE_CONFIG` in your CI provider of choice. Mintable will handle the rest. - -> **Note:** Some CI providers (like Travis) require you to wrap this variable in single quotes, i.e. `'{ "ACCOUNT_PROVIDER": "plaid", ...}'`. If you get an error similar to `Unable to parse JSON...` when you run your CI build, give this a try. - -> **Warning:** If you choose to use CircleCI, you should turn off **Pass secrets to builds from forked pull requests** under **Build Settings** > **Advanced Settings**. - -## Fetching Balances - -#### Create Balances Sheet - -`CREATE_BALANCES_SHEET` optionally fetches the balances of all your connected accounts and places them in a sheet called `Balances`. - -**Default:** - -```javascript -"CREATE_BALANCES_SHEET": undefined // If unspecified, defaults to false -``` - -If you want to enable this, you can add the following line to your `mintable.config.json` file: - -```javascript -"CREATE_BALANCES_SHEET": true -``` - -#### Balance Columns - -`BALANCE_COLUMNS` specifies a list of account properties (using [`_.get()` syntax](https://lodash.com/docs/4.17.11#get)) to automatically update in your `Balances` spreadsheet. All the contents of these columns will be cleared and overwritten each time you run Mintable. - -**Default:** - -```javascript -"BALANCE_COLUMNS": ['name', 'official_name', 'type', 'balances.available', 'balances.current', 'balances.limit'] -``` - -For example, if you only want to auto-populate the name and amount for each account, you could add the following line to your `mintable.config.json` file: - -```javascript -"TRANSACTION_COLUMNS": ["name", "balances.current"] -``` - -## Fetching Transactions - -#### Start Date - -`START_DATE` specifies the lower bound for fetching transactions in `YYYY.MM.DD` format. - -**Default:** - -```javascript -"START_DATE": undefined // If end date is not specified, Mintable will fetch the last 2 months of transactions -``` - -For example, if you only want to fetch transactions which occur after or on December 1, 2018, you could add the following line to your `mintable.config.json` file: - -```javascript -"START_DATE": "2018.12.01" -``` - -#### End Date - -`END_DATE` specifies the upper bound for fetching transactions in `YYYY.MM.DD` format. - -**Default:** - -```javascript -"END_DATE": undefined // If end date is not specified, Mintable will fetch up until the current date -``` - -For example, if you only want to fetch transactions which occur before or on December 1, 2018, you could add the following line to your `mintable.config.json` file: - -```javascript -"END_DATE": "2018.12.01" -``` - -#### Transaction Columns - -`TRANSACTION_COLUMNS` specifies a list of transaction properties (using [`_.get()` syntax](https://lodash.com/docs/4.17.11#get)) to automatically update in your spreadsheet. All the contents of these columns will be cleared and overwritten each time you run Mintable. - -**Default:** - -```javascript -"TRANSACTION_COLUMNS": [ 'date', 'amount', 'name', 'account_details.official_name', 'category.0', 'category.1', 'pending' ] -``` - -For example, if you only want to auto-populate the name and amount for each transaction, you could add the following line to your `mintable.config.json` file: - -```javascript -"TRANSACTION_COLUMNS": ["name", "amount"] -``` - -#### Reference Columns - -`REFERENCE_COLUMNS` specifies a list of additional, non-automated columns for your reference/bookkeeping purposes. Each time you run Mintable, the contents of these columns will be preserved. - -**Default:** - -```javascript -"REFERENCE_COLUMNS": ['notes', 'work', 'joint'] -``` - -For example, if you want to add one column to track work expenses, and another to track joint expenses shared with a partner, you could add the following line to your `mintable.config.json` file: - -```javascript -"REFERENCE_COLUMNS": ["work", "joint"] -``` - -> **Warning:** Since reference columns are not automated by Mintable, they have the potential to get out of sync with transaction data (for example, if your bank deletes a transaction, causing a row to get removed in `TRANSACTION_COLUMNS`) - -# Provider-Specific Configuration - -You can see the API definitions for account & spreadsheet providers in the **[provider docs](./docs/PROVIDERS.md)**. - -## Plaid - -#### Category Overrides - -`CATEGORY_OVERRIDES` specifies a list of overrides to handle transactions that are routinely miscategorized by Plaid's servers. - -**Default:** - -```javascript -"CATEGORY_OVERRIDES": [] -``` - -Overrides take the following format: - -* `pattern`: [JavaScript Regular Expression](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#Syntax) to test transaction names against -* `flags`: [JavaScript Regular Expression flags](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#Syntax) (i.e. `i` for case insensitive) -* `category.0`: Override for first (top-level) category -* `category.1`: Override for second (level-2) category - -For example, if you want anything matching `autopay` or `e-payment` to get categorized as `Credit Card Payment`, you could add the following lines to your `mintable.config.json` file: - -```javascript -"CATEGORY_OVERRIDES": [ - { - "pattern": ".*(autopay|e.payment).*", - "flags": "i", - "category.0": "Transfer", - "category.1": "Credit Card Payments" - } -] -``` - -## Google Sheets - -#### Template Sheet - -`TEMPLATE_SHEET` specifies the template spreadsheet to use when creating a _new_ sheet for a month. - -**Default:** - -```javascript -"TEMPLATE_SHEET": { - // Public template: https://docs.google.com/spreadsheets/d/10fYhPJzABd8KlgAzxtiyFN-L_SebTvM8SaAK_wHk-Fw - "SHEET_ID": "10fYhPJzABd8KlgAzxtiyFN-L_SebTvM8SaAK_wHk-Fw", - "SHEET_TITLE": "Template" -} -``` - -* `SHEET_ID`: Google Sheets spreadsheet ID (from the URL: `docs.google.com/spreadsheets/d/`**`sheet_id`**`/edit`) -* `SHEET_TITLE`: Title of the sheet (along the bottom row of the document) - -For example, you could add the following lines to your `mintable.config.json` file: - -```javascript -"TEMPLATE_SHEET": { - "SHEET_ID": "10fYhPJzABd8KasbqiyFN-L_SebTvM8SaAK_wHk-Fw", - "SHEET_TITLE": "My Template Sheet" -} -``` diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md deleted file mode 100644 index 7f54cfdf..00000000 --- a/docs/PROVIDERS.md +++ /dev/null @@ -1,47 +0,0 @@ -# Providers APIs - -Mintable is designed to be pluggable, i.e. you can swap out Plaid or Google Sheets for another service of your choice. - -This document outlines what Mintable expects of a provider, and what constraints/functionality you need to adhere to/implement if you want to add a new provider. - -## Transactions (`ACCOUNT_PROVIDER`) API - -Account providers should provide an exported function which takes in a startDate and endDate, and returns a `Promise` of a raw list of transaction objects, i.e.: - -```javascript -transactions = await require('../lib/providerName').fetchTransactions(startDate, endDate) -``` - -At minimum, we expect `name`, `date`, and `amount` to be defined. - -For example, the following would an acceptable response (after promise resolution): - -```javascript -[ - { "name": "Amazon.com", "date": "2019-04-16T07:00:00.000Z", "amount": -40.22 }, - { "name": "United Airlines", "date": "2019-04-06T07:00:00.000Z", "amount": -500 }, - { "name": "Uber", "date": "2019-04-04T07:00:00.000Z", "amount": -6.33 } -] -``` - -## Spreadsheets (`SHEET_PROVIDER`) API - -Spreadsheet providers should provide an exported function which takes in a map of sheet name to a list of transactions for that sheet, and return a `Promise` which resolves when all necessary operations to update that sheet are complete, i.e.: - -```javascript -await require('../lib/providerName').updateSheets(updates, options) -``` - -where `updates` come in the following format: - -```javascript -{ - "2019.04": [ - { "name": "Amazon.com", "date": "2019.04.16", "amount": -40.22 }, - { "name": "United Airlines", "date": "2019.04.26", "amount": -500 } - ], - "2019.05": [ - { "name": "Uber", "date": "2019.05.11", "amount": -6.33 } - ] -} -``` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..8367e29c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,331 @@ +# Documentation + +#### Table of Contents + ++ [Overview](#overview) ++ [Installation](#installation) + + [Creating a Fresh Installation](#creating-a-fresh-installation) + + [Migrating from `v1.x.x`](#migrating-from-v1xx) ++ [Importing Account Balances & Transactions](#importing-account-balances--transactions) + + [Automatically – in the cloud – via Plaid](#automatically-in-the-cloud--via-plaid) + + [Manually – on your local machine – via CSV bank statements](#manually--on-your-local-machine--via-csv-bank-statements) ++ [Exporting Account Balances & Transactions](#exporting-account-balances--transactions) + + [In the cloud – via Google Sheets](#in-the-cloud-via-google-sheets) + + [On your local machine – via CSV files](#on-your-local-machine--via-csv-files) ++ [Updating Transactions/Accounts](#updating-transactionsaccounts) + + [Manually – in your local machine's terminal](#manually-in-your-local-machines-terminal) + + [Automatically – in your Mac's Menu Bar – via BitBar](#automatically-in-your-macs-menu-bar--via-bitbar) + + [Automatically – in your local machine's terminal – via `cron`](#automatically-in-your-local-machines-terminal--via-cron) + + [Automatically – in the cloud – via GitHub Actions](#automatically-in-the-cloud--via-github-actions) ++ [Transaction Rules](#transaction-rules) + + [Transaction `filter` Rules](#transaction-filter-rules) + + [Transaction `override` Rules](#transaction-override-rules) ++ [Development](#development) ++ [Contributing](#contributing) + +## Overview + +![Mintable](./img/mintable.png) + +Mintable simplifies managing your finances, for free, without ads, and without tracking your information. Here's how it works: + +1. You connect your accounts and a spreadsheet to Mintable. +1. Mintable integrates with your financial institutions to automatically populate transactions into the leftmost columns in your spreadsheet. +1. You can add whatever formulas, charts, or calculations you want to the right of your transactions (just like a normal spreadsheet). We also have templates to get you started. + +--- + +## Installation + +### Creating a Fresh Installation + +1. Sign up for [Plaid's Free Plan](https://plaid.com/pricing/). The free plan is limited to 100 banking institutions which should be more than enough for personal use. After applying and verifying your email it usually takes a day or two for them to approve your account. +2. Install the global `mintable` command line utility: + + ```bash + npm install -g mintable + ``` + +3. Set up the integration with your banks and a spreadsheet using the setup wizard: + + ```bash + mintable setup + ``` + +4. Update your account balances/transactions: + + ``` + mintable fetch + ``` + +![Mintable CLI](./img/cli.png) + +### Migrating from `v1.x.x` + +> **⚠️ Warning:** Plaid [introduced a breaking change in July 2020](https://github.com/plaid/plaid-node/pull/310) which deprecates the Public Key component from the authentication process. Once you upgrade to `v2.x.x` and disable your Public Key, you will no longer be able to continue using your `v1.x.x` installation. Proceed with caution. + +1. [Disable the Public Key in your Plaid Dashboard](https://plaid.com/docs/upgrade-to-link-tokens/#disable-the-public-key) (read ⚠️ above!) + +2. Install the new `v2.x.x` `mintable` command line utility: + + ```bash + npm install -g mintable + ``` + +3. Migrate your config to the new format: + + ```bash + mintable migrate --old-config-file /path/to/your/old/mintable.config.json + ``` + +4. Update your account balances/transactions: + + ```bash + mintable fetch + ``` + +> **Note:** After successful migration you can delete everything in your `v1.x.x` `mintable` folder. You may want to keep a copy of your `mintable.config.json` for posterity. + +--- + +## Importing Account Balances & Transactions + +### Automatically – in the cloud – via [Plaid](https://plaid.com) + +You can run: + +```bash +mintable plaid-setup +``` + +to enter the Plaid setup wizard. This will allow you to automatically fetch updated account balances/transactions from your banking institutions every time `mintable fetch` is run. + +After you have the base Plaid integration working, you can run: + +```bash +mintable account-setup +``` + +to enter the account setup wizard to add, update, or remove accounts. + +![Account Setup](./img/account-setup.png) + +This will launch a local web server (necessary to authenticate with Plaid's servers) for you to connect your banks. + +To add a new account, click the blue **Link A New Account** button. To re-authenticate with an existing account, click the blue **Update** button next to the account name in the table. + +> **Note:** Plaid is the default import integration and these steps are not necessary if you've already run `mintable setup`. + +### Manually – on your local machine – via CSV bank statements + +You can run: + +```bash +mintable csv-import-setup +``` + +to enter the CSV import setup wizard. This will allow you to manually import files or globs (`path/to/my/folder/transactions/*.csv`) every time `mintable fetch` is run. + +You'll need to define a transformer to map properties in your source CSV spreadsheet to valid Mintable transaction properties, and a valid date format. + +We have a number of templates available for popular financial institutions to get you started: + +- [Apple Card](./templates/apple-card.json) +- [Discover Card](./templates/discover-card.json) +- [Venmo](./templates/venmo.json) +- [Chase](./templates/chase.json) +- [American Express](./templates/american-express.json) +- [Rogers Bank Credit Card](./templates/rogers-bank-credit-card.json) + +These templates can be added into the `accounts` section of your `mintable.jsonc` configuration file. + +> **Note:** CSV Imports do not support account balances. + +--- + +## Exporting Account Balances & Transactions + +### In the cloud – via [Google Sheets](https://www.google.com/sheets/about/) + +You can run: + +```bash +mintable google-setup +``` + +to enter the Google Sheets setup wizard. This will allow you to automatically update a sheet with your transactions/account balances every time `mintable fetch` is run. + +> **Note:** Google Sheets is the default export integration and this step is not necessary if you've already run `mintable setup`. + +### On your local machine – via CSV files + +You can run: + +```bash +mintable csv-export-setup +``` + +to enter the CSV export setup wizard. This will allow you to manually export a CSV containing your transactions/account balances every time `mintable fetch` is run. + +--- + +## Updating Transactions/Accounts + +### Manually – in your local machine's terminal + +After you have connected a banking institution, you can run: + +```bash +mintable fetch +``` + +to automate updates to your spreadsheet. + +### Automatically – in your Mac's Menu Bar – via [BitBar](https://github.com/matryer/bitbar#get-started) + +You can put Mintable in your Mac's menu bar, and have it run automatically every hour using our [BitBar Plugin](https://github.com/matryer/bitbar-plugins/pull/1460). + +![BitBar](./img/bitbar.png) + +1. [Install BitBar](https://github.com/matryer/bitbar/releases) on your Mac. +2. Set your plugin folder. +3. Create a new file in `mintable.1h.zsh` in your plugin folder. +4. Copy & paste [this](https://github.com/matryer/bitbar-plugins/blob/39e8f252ed69d0dd46bbe095299e52279e86d737/Finance/mintable.1h.zsh) into the file you just created and save. +5. Open **BitBar** > **Preferences** > **Refresh All** to update your spreadsheet. + +> **Note:** The plugin above is pending approval and this install process should be much easier moving forward. + +### Automatically – in your local machine's terminal – via `cron` + +You can run Mintable automatically within your terminal using `cron`: + +![`cron`](./img/cron.png) + +```bash +echo "0 * * * * export PATH="/usr/local/bin:$PATH" && mintable fetch" > ~/mintable.cron +crontab ~/mintable.cron +``` + +The first step creates a new file `~/mintable.cron` which contains an interval and the command you want to run. The second step registers that file with `crontab`, the command-line executable which actually schedules the job with your operating system. + +The default refresh interval is 1 hour – you can use [Crontab Guru](https://crontab.guru/) to define your own interval. + +You can remove this schedule by running: + +```bash +crontab -r +``` + +> **Note:** The instructions above assume your global `mintable` CLI lives in `/usr/local/bin`, but if your installation path is different (run `which mintable`) you should use that instead. + +### Automatically – in the cloud – via GitHub Actions + +You can use GitHub Actions to run Mintable automatically in the cloud: + +![GitHub Actions](./img/github-actions.png) + +1. Fork [this repo](https://github.com/kevinschaich/mintable). +2. Go to your repo's **Actions** > Click **I understand my workflows, go ahead and enable them** +3. Go to your repo's **Settings** > **Secrets** and add a **New Secret**. +4. Name the secret `MINTABLE_CONFIG`, and copy and paste the full contents of your `~/mintable.jsonc` file into the body of the secret. +5. In your repo's `./.github/workflows/fetch.yml`, uncomment the following block and commit the changes: + + ``` + # schedule: + # - cron: '0 * * * *' + ``` + +In the **Actions** tab of your repo, the **Fetch** workflow will now update your sheet periodically. The default refresh interval is 1 hour – you can use [Crontab Guru](https://crontab.guru/) to define your own interval. + +> **Note:** The minimum interval supported by GitHub Actions is every 5 minutes. + +--- + +## Transaction Rules + +### Transaction `filter` Rules + +Transaction `filter` rules allow you to exclude transactions from your spreadsheet based on a set of [conditions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions). + +For example, if you wanted to exclude any cross-account transfers, you might add the following to your `transactions.rules` config: + +```json +"rules": [ + { + "conditions": [ + { + "property": "name", + "pattern": "(transfer|xfer|trnsfr)", + "flags": "ig" + } + ], + "type": "filter" + } +] +``` + +### Transaction `override` Rules + +Transaction `override` rules allow you to override auto-populated fields based on a set of [conditions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions), search for a [pattern](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions), and replace it with another [pattern](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions). + +You might want to do this to standardize values between financial institutions (`XFER` -> `Transfer`), or tune things to suit your particular budgeting needs (described below). + +For example, let's say you want to know how much you are spending on coffee each month, but Plaid/your bank categorizes your favorite shops as `Restaurants – Fast Food`. You might add the following to your `transactions.rules` config: + +```json +"rules": [ + { + "conditions": [ + { + "property": "name", + "pattern": "(dunkin|starbucks|peets|philz)", + "flags": "ig" + } + ], + "type": "override", + "property": "category", + "findPattern": "Fast Food", + "replacePattern": "Coffee Shops", + "flags": "i" + } +] +``` + +When you run `mintable fetch` the next time, the category would be `Restaurants – Coffee Shops`. + +## Development + +To get started: + +```bash +git clone https://github.com/kevinschaich/mintable +cd mintable + +npm install +npm run build +npm link +``` + +The global `mintable` command will now point to your local version (`/lib/scripts/cli.js`). To start compilation in watch mode: + +```bash +npm run watch +``` + +To publish a new version, increment `version` in `package.json` and run: + +```bash +npm run build +npm publish +``` + +To revert to the production release of `mintable`, run: + +```bash +npm unlink +npm install -g mintable +``` + +## Contributing + +Before posting please check if your issue has already been reported. We'll gladly accept PRs, feature requests, or bugs via [Issues](https://github.com/kevinschaich/mintable/issues). diff --git a/docs/css/account-setup.css b/docs/css/account-setup.css new file mode 100644 index 00000000..e1b86aa6 --- /dev/null +++ b/docs/css/account-setup.css @@ -0,0 +1,119 @@ +body { + display: flex; + justify-content: center; + align-items: center; + text-align: center; +} + +h1, +h2, +h3, +p, +th, +tr, +button { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; + text-rendering: optimizeLegibility; +} + +#logo { + width: 150px; +} + +table { + border-collapse: collapse; +} + +h1 { + font-weight: 500; + font-size: 60px; + margin-top: 40px; + color: #333; +} + +h2 { + font-weight: 300; + margin-top: 40px; + color: #333; +} + +h3 { + font-size: 18px; + font-weight: 500; +} + +th { + font-weight: 700; +} + +td { + font-weight: 400; + color: #333; +} + +#accounts { + margin-top: 80px; +} + +#accounts-table { + border-radius: 5px; + background: rgb(245, 245, 245); + border: 1px solid rgb(235, 235, 235); +} + +p { + padding: 10px 40px; +} + +th { + padding: 20px; +} + +td { + padding: 10px 20px; + border-top: 1px solid rgb(235, 235, 235); + background: #fff; +} + +button { + font-size: 18px; + font-weight: 500; + border-radius: 5px; + padding: 10px 40px; + cursor: pointer; + background-color: transparent; + border: 1px solid #0a85ea; + color: #0a85ea; + transition: all 300ms; +} + +button:hover { + color: white; + background-color: #0a85ea; +} + +button.remove { + border: 1px solid #ed0d3a; + color: #ed0d3a; +} + +button.remove:hover { + color: white; + background-color: #ed0d3a; +} + +#link-button { + margin-top: 80px; +} + +#done-button { + border: 1px solid #2abf54; + color: #2abf54; + margin-top: 40px; +} + +#done-button:hover { + color: white; + background-color: #2abf54; +} diff --git a/docs/img/account-setup.png b/docs/img/account-setup.png new file mode 100644 index 00000000..3a2b9ea1 Binary files /dev/null and b/docs/img/account-setup.png differ diff --git a/docs/img/bitbar.png b/docs/img/bitbar.png new file mode 100644 index 00000000..4952d567 Binary files /dev/null and b/docs/img/bitbar.png differ diff --git a/docs/img/cli.png b/docs/img/cli.png new file mode 100644 index 00000000..2e45d7e4 Binary files /dev/null and b/docs/img/cli.png differ diff --git a/docs/img/cron.png b/docs/img/cron.png new file mode 100644 index 00000000..417ac2c1 Binary files /dev/null and b/docs/img/cron.png differ diff --git a/docs/img/github-actions.png b/docs/img/github-actions.png new file mode 100644 index 00000000..82671608 Binary files /dev/null and b/docs/img/github-actions.png differ diff --git a/src/static/icon.png b/docs/img/icon.png similarity index 100% rename from src/static/icon.png rename to docs/img/icon.png diff --git a/src/static/logo.png b/docs/img/logo.png similarity index 100% rename from src/static/logo.png rename to docs/img/logo.png diff --git a/src/static/mintable.png b/docs/img/mintable.png similarity index 100% rename from src/static/mintable.png rename to docs/img/mintable.png diff --git a/docs/templates/american-express.json b/docs/templates/american-express.json new file mode 100644 index 00000000..210e8c3d --- /dev/null +++ b/docs/templates/american-express.json @@ -0,0 +1,17 @@ +{ + "accounts": { + "American Express": { + "paths": ["/path/to/my/american/express/statements/*.csv"], + "transformer": { + "Description": "name", + "Date": "date", + "Amount": "amount", + "Category": "category" + }, + "dateFormat": "MM/dd/yyyy", + "id": "American Express", + "integration": "csv-import", + "negateValues": true + } + } +} diff --git a/docs/templates/apple-card.json b/docs/templates/apple-card.json new file mode 100644 index 00000000..cec17d31 --- /dev/null +++ b/docs/templates/apple-card.json @@ -0,0 +1,17 @@ +{ + "accounts": { + "Apple Card": { + "paths": ["/path/to/my/apple/card/statements/*.csv"], + "transformer": { + "Merchant": "name", + "Transaction Date": "date", + "Amount (USD)": "amount", + "Category": "category" + }, + "dateFormat": "MM/dd/yyyy", + "id": "Apple Card", + "integration": "csv-import", + "negateValues": true + } + } +} diff --git a/docs/templates/chase.json b/docs/templates/chase.json new file mode 100644 index 00000000..a3739e25 --- /dev/null +++ b/docs/templates/chase.json @@ -0,0 +1,17 @@ +{ + "accounts": { + "Chase": { + "paths": ["/path/to/my/chase/statements/*.csv"], + "transformer": { + "Description": "name", + "Transaction Date": "date", + "Amount": "amount", + "Category": "category" + }, + "dateFormat": "MM/dd/yyyy", + "id": "Chase", + "integration": "csv-import", + "negateValues": true + } + } +} diff --git a/docs/templates/discover-card.json b/docs/templates/discover-card.json new file mode 100644 index 00000000..1558d9d2 --- /dev/null +++ b/docs/templates/discover-card.json @@ -0,0 +1,17 @@ +{ + "accounts": { + "Discover Card": { + "paths": ["/path/to/my/discover/card/statements/*.csv"], + "transformer": { + "Description": "name", + "Trans. Date": "date", + "Amount": "amount", + "Category": "category" + }, + "dateFormat": "MM/dd/yyyy", + "id": "Discover Card", + "integration": "csv-import", + "negateValues": true + } + } +} diff --git a/docs/templates/rogers-bank-credit-card.json b/docs/templates/rogers-bank-credit-card.json new file mode 100644 index 00000000..b4482cf7 --- /dev/null +++ b/docs/templates/rogers-bank-credit-card.json @@ -0,0 +1,17 @@ +{ + "accounts": { + "Rogers Bank Credit Card": { + "paths": ["/path/to/my/rogers/bank/credit/card/statements/*.csv"], + "transformer": { + "Merchant Name": "name", + "Date": "date", + "Amount": "amount", + "Merchant Category Description": "category" + }, + "dateFormat": "yyyy/MM/dd", + "id": "Rogers Bank Credit Card", + "integration": "csv-import", + "negateValues": true + } + } +} diff --git a/docs/templates/venmo.json b/docs/templates/venmo.json new file mode 100644 index 00000000..d9f59ae0 --- /dev/null +++ b/docs/templates/venmo.json @@ -0,0 +1,17 @@ +{ + "accounts": { + "Venmo": { + "paths": ["/path/to/my/venmo/statements/*.csv"], + "transformer": { + "Note": "name", + "Datetime": "date", + "Amount (total)": "amount", + "Type+From+To": "category" + }, + "dateFormat": "yyyy-MM-dd'T'HH:mm:ss", + "id": "Venmo", + "integration": "csv-import", + "negateValues": false + } + } +} diff --git a/package.json b/package.json index c2aafaf9..73f91891 100644 --- a/package.json +++ b/package.json @@ -1,45 +1,80 @@ { - "name": "mintable", - "private": true, - "main": "mintable.js", - "author": "Kevin Schaich", - "license": "MIT", - "version": "1.2.5", - "scripts": { - "circleci:dry-run": "circleci local execute -e MINTABLE_CONFIG=$MINTABLE_CONFIG", - "export": "node ./src/scripts/export.js", - "format": "yarn prettier", - "migrate": "node ./src/scripts/migrate.js", - "mintable": "node ./src/scripts/mintable.js", - "setup": "cd src && node ./scripts/setup.js", - "prettier": "prettier --write ./src/**/*.{css,scss,less,js,jsx,ts,tsx,json} --ignore-path ./.gitignore" - }, - "dependencies": { - "@zeit/next-sass": "^1.0.1", - "body-parser": "^1.18.3", - "clipboardy": "^1.2.3", - "date-fns": "^1.30.1", - "dotenv": "^7.0.0", - "express": "^4.16.4", - "googleapis": "27", - "indent-string": "^3.2.0", - "isomorphic-unfetch": "^3.0.0", - "lodash": "^4.17.11", - "log-symbols": "^2.2.0", - "next": "^8.0.3", - "node-sass": "^4.11.0", - "opn": "^6.0.0", - "ora": "^3.4.0", - "p-each-series": "^2.1.0", - "p-map-series": "^2.1.0", - "plaid": "^2.10.0", - "promise-fs": "^2.1.0", - "react": "^16.8.6", - "react-dom": "^16.8.6", - "react-icons": "^3.5.0", - "react-plaid-link": "^1.2.0" - }, - "devDependencies": { - "prettier": "^1.16.4" - } + "name": "mintable", + "author": "Kevin Schaich (http://kevinschaich.io)", + "license": "MIT", + "version": "2.0.0-beta.47", + "bin": "./lib/scripts/cli.js", + "preferGlobal": true, + "scripts": { + "build": "tsc", + "watch": "tsc --watch", + "lint": "prettier --check ./src/**/*.js ./src/**/*.html ./src/**/*.ts ./docs/**/*.json", + "lint-fix": "prettier --write ./src/**/*.js ./src/**/*.html ./src/**/*.ts ./docs/**/*.json", + "test": "npm run build && npm run lint" + }, + "files": [ + "src", + "lib", + "docs", + "tsconfig.json" + ], + "dependencies": { + "@types/body-parser": "^1.19.0", + "@types/express": "^4.17.3", + "@types/glob": "^7.1.2", + "@types/lodash": "^4.14.149", + "@types/node": "^14.0.13", + "@types/prompts": "^2.0.3", + "ajv": "^6.12.0", + "body-parser": "^1.19.0", + "chalk": "^3.0.0", + "csv-parse": "^4.10.1", + "csv-stringify": "^5.5.0", + "date-fns": "^2.10.0", + "express": "^4.17.1", + "glob": "^7.1.6", + "googleapis": "47.0.0", + "jsonc": "^2.0.0", + "lodash": "^4.17.15", + "open": "^7.0.2", + "plaid": "^7.0.0", + "prompts": "^2.3.1", + "typescript": "^3.8.3", + "typescript-json-schema": "^0.42.0", + "yargs": "^15.1.0" + }, + "devDependencies": { + "prettier": "^1.16.4" + }, + "description": "Automate your personal finances – for free, with no ads, and no data collection.", + "repository": { + "type": "git", + "url": "git+https://github.com/kevinschaich/mintable.git" + }, + "keywords": [ + "finance", + "finance-management", + "personal-finance", + "mint", + "sheets-api", + "google-sheets", + "google-sheets-api", + "plaid", + "plaid-api", + "analytics", + "tracker", + "finance-tracker", + "personal-capital", + "spreadsheet", + "mintable", + "money", + "budget", + "budgeting", + "budget-management", + "javascript" + ], + "bugs": { + "url": "https://github.com/kevinschaich/mintable/issues" + }, + "homepage": "https://github.com/kevinschaich/mintable#readme" } diff --git a/src/common/config.ts b/src/common/config.ts new file mode 100644 index 00000000..0544140f --- /dev/null +++ b/src/common/config.ts @@ -0,0 +1,203 @@ +import { IntegrationConfig, IntegrationId } from '../types/integrations' +import { AccountConfig } from '../types/account' +import { TransactionConfig } from '../types/transaction' +import { logInfo, logError } from './logging' +import { argv } from 'yargs' +import * as fs from 'fs' +import * as os from 'os' +import { resolve, join } from 'path' +import { Definition, CompilerOptions, PartialArgs, getProgramFromFiles, generateSchema } from 'typescript-json-schema' +import Ajv from 'ajv' +import { BalanceConfig } from '../types/balance' +import { jsonc } from 'jsonc' + +const DEFAULT_CONFIG_FILE = '~/mintable.jsonc' +const DEFAULT_CONFIG_VAR = 'MINTABLE_CONFIG' + +const DEFAULT_CONFIG: Config = { + accounts: {}, + transactions: { + integration: IntegrationId.Google, + properties: ['date', 'amount', 'name', 'account', 'category'] + }, + balances: { + integration: IntegrationId.Google, + properties: ['institution', 'account', 'type', 'current', 'available', 'limit', 'currency'] + }, + integrations: {} +} + +export interface FileConfig { + type: 'file' + path: string +} + +export interface EnvironmentConfig { + type: 'environment' + variable: string +} + +export type ConfigSource = FileConfig | EnvironmentConfig + +export interface Config { + integrations: { [id: string]: IntegrationConfig } + accounts: { [id: string]: AccountConfig } + transactions: TransactionConfig + balances: BalanceConfig +} + +export const getConfigSource = (): ConfigSource => { + if (argv['config-file']) { + const path = argv['config-file'].replace(/^~(?=$|\/|\\)/, os.homedir()) + logInfo(`Using configuration file \`${path}.\``) + return { type: 'file', path: path } + } + + if (process.env[DEFAULT_CONFIG_VAR]) { + logInfo(`Using configuration variable '${DEFAULT_CONFIG_VAR}.'`) + return { type: 'environment', variable: DEFAULT_CONFIG_VAR } + } + + // Default to DEFAULT_CONFIG_FILE + const path = DEFAULT_CONFIG_FILE.replace(/^~(?=$|\/|\\)/, os.homedir()) + logInfo(`Using default configuration file \`${path}.\``) + logInfo(`You can supply either --config-file or --config-variable to specify a different configuration.`) + return { type: 'file', path: path } +} + +export const readConfig = (source: ConfigSource, checkExists?: boolean): string => { + if (source.type === 'file') { + try { + const config = fs.readFileSync(source.path, 'utf8') + logInfo('Successfully opened configuration file.') + return config + } catch (e) { + if (checkExists) { + logInfo('Unable to open config file.') + } else { + logError('Unable to open configuration file.', e) + logInfo("You may want to run `mintable setup` (or `mintable migrate`) if you haven't already.") + } + } + } + if (source.type === 'environment') { + try { + const config = process.env[source.variable] + + if (config === undefined) { + throw `Variable \`${source.variable}\` not defined in environment.` + } + + logInfo('Successfully retrieved configuration variable.') + return config + } catch (e) { + if (!checkExists) { + logInfo('Unable to read config variable from env.') + } else { + logError('Unable to read config variable from env.', e) + } + } + } +} + +export const parseConfig = (configString: string): Object => { + try { + const parsedConfig = jsonc.parse(configString) + logInfo('Successfully parsed configuration.') + return parsedConfig + } catch (e) { + logError('Unable to parse configuration.', e) + } +} + +export const getConfigSchema = (): Definition => { + const basePath = resolve(join(__dirname, '../..')) + const config = resolve(join(basePath, 'src/common/config.ts')) + const tsconfig = require(resolve(join(basePath, 'tsconfig.json'))) + const types = resolve(join(basePath, 'node_modules/@types')) + + // Generate JSON schema at runtime for Config interface above + const compilerOptions: CompilerOptions = { + ...tsconfig.compilerOptions, + typeRoots: [types], + baseUrl: basePath + } + + const settings: PartialArgs = { + required: true, + defaultProps: true, + noExtraProps: true + } + + try { + const program = getProgramFromFiles([config], compilerOptions, basePath) + const configSchema = generateSchema(program, 'Config', settings) + + return configSchema + } catch (e) { + logError('Could not generate config schema.', e) + } +} + +export const validateConfig = (parsedConfig: Object): Config => { + const configSchema = getConfigSchema() + + // Validate parsed configuration object against generated JSON schema + try { + const validator = new Ajv() + const valid = validator.validate(configSchema, parsedConfig) + + if (!valid) { + logError('Unable to validate configuration.', validator.errors) + } + } catch (e) { + logError('Unable to validate configuration.', e) + } + + const validatedConfig = parsedConfig as Config + logInfo('Successfully validated configuration.') + return validatedConfig +} + +export const getConfig = (): Config => { + const configSource = getConfigSource() + const configString = readConfig(configSource) + const parsedConfig = parseConfig(configString) + const validatedConfig = validateConfig(parsedConfig) + return validatedConfig +} + +export const writeConfig = (source: ConfigSource, config: Config): void => { + if (source.type === 'file') { + try { + fs.writeFileSync(source.path, jsonc.stringify(config, null, 2)) + logInfo('Successfully wrote configuration file.') + } catch (e) { + logError('Unable to write configuration file.', e) + } + } + if (source.type === 'environment') { + logError( + 'Node does not have permissions to modify global environment variables. Please use file-based configuration to make changes.' + ) + } +} + +type ConfigTransformer = (oldConfig: Config) => Config + +export const updateConfig = (configTransformer: ConfigTransformer, initialize?: boolean): Config => { + let newConfig: Config + const configSource = getConfigSource() + + if (initialize) { + newConfig = configTransformer(DEFAULT_CONFIG) + } else { + const configString = readConfig(configSource) + const oldConfig = parseConfig(configString) as Config + newConfig = configTransformer(oldConfig) + } + + const validatedConfig = validateConfig(newConfig) + writeConfig(configSource, validatedConfig) + return validatedConfig +} diff --git a/src/common/logging.ts b/src/common/logging.ts new file mode 100644 index 00000000..e1781b6f --- /dev/null +++ b/src/common/logging.ts @@ -0,0 +1,121 @@ +const chalk = require('chalk') +import { argv } from 'yargs' +import { inspect } from 'util' +import * as os from 'os' + +export enum LogLevel { + Info = 'info', + Warn = 'warn', + Error = 'error' +} + +export interface LogRequest { + level: LogLevel + message: string + data?: any +} + +const sanitize = (data: any) => { + const blacklist = [ + 'account.?id', + 'account', + 'client.?id', + 'client.?secret', + 'private.?key', + 'private.?token', + 'public.?key', + 'public.?token', + 'refresh.?token', + 'secret', + 'spreadsheet.?id', + 'spreadsheet', + 'token' + ] + + if (typeof data === 'string') { + blacklist.forEach(term => { + data = data.replace(RegExp(`(${term}).?(.*)`, 'gi'), `$1=`) + }) + return data + } else if (typeof data === 'boolean') { + return data + } else if (typeof data === 'number') { + return data + } else if (Array.isArray(data)) { + return data.map(sanitize) + } else if (typeof data === 'object') { + let sanitized = {} + for (const key in data) { + sanitized[sanitize(key) as string] = sanitize(data[key]) + } + return sanitized + } else { + return '[redacted]' + } +} + +export const log = (request: LogRequest): void => { + if (argv['ci']) { + request.message = sanitize(request.message) + request.data = sanitize(request.data) + } + + const date = chalk.bold(new Date().toISOString()) + const level = chalk.bold(`[${request.level.toUpperCase()}]`) + const text = `${date} ${level} ${request.message}` + + switch (request.level) { + case LogLevel.Error: + console.error(chalk.red(text)) + console.error('\n', chalk.red(inspect(request.data, true, 10)), '\n') + + const searchIssuesLink = encodeURI( + `https://github.com/kevinschaich/mintable/issues?q=is:issue+${request.message}` + ) + const searchIssuesHelpText = `You can check if anybody else has encountered this issue on GitHub:\n${searchIssuesLink}\n` + console.warn(chalk.yellow(searchIssuesHelpText)) + + const systemInfo = `arch: ${os.arch()}\nplatform: ${os.platform()}\nos: v${os.release()}\nmintable: v${ + require('../../package.json').version + }\nnode: ${process.version}` + const reportIssueBody = + '**Steps to Reproduce:**\n\n1.\n2.\n3.\n\n**Error:**\n\n```\n\n```\n\n**System Info:**\n\n```\n' + + systemInfo + + '\n```' + const reportIssueLink = encodeURI( + `https://github.com/kevinschaich/mintable/issues/new?title=Error:+${request.message}&body=${reportIssueBody}` + ) + const reportIssueHelpText = `If this is a new issue, please use this link to report it:\n${reportIssueLink}\n` + console.warn(chalk.yellow(reportIssueHelpText)) + + process.exit(1) + case LogLevel.Warn: + console.warn(chalk.yellow(text)) + break + case LogLevel.Info: + console.info(text) + break + default: + break + } + + if (argv['debug']) { + try { + console.log('\n', inspect(request.data, true, 10), '\n') + } catch (e) { + console.log('\n', JSON.stringify(request.data, null, 2), '\n') + } + } +} + +export const logError = (message: string, data?: any): void => { + log({ level: LogLevel.Error, message, data }) +} + +export const logWarn = (message: string, data?: any): void => { + log({ level: LogLevel.Warn, message, data }) +} + +export const logInfo = (message: string, data?: any): void => { + log({ level: LogLevel.Info, message, data }) +} diff --git a/src/components/account.jsx b/src/components/account.jsx deleted file mode 100644 index fd1d2dfa..00000000 --- a/src/components/account.jsx +++ /dev/null @@ -1,63 +0,0 @@ -import { FiRefreshCw, FiTrash2, FiXCircle, FiCheckCircle } from 'react-icons/fi' -import { fetch } from './helpers' - -const Account = props => { - let status = - if (props.details.error) { - status = ( - - - Error - - ) - } - - const handleOnClickDelete = e => { - if (confirm(`Are you sure you want to remove the account ${props.details.nickname}?`)) { - const body = { - id: `PLAID_TOKEN_${props.details.nickname}` - } - - fetch(`http://${process.env.HOST}:${process.env.PORT}/config`, { - method: 'DELETE', - body: JSON.stringify(body), - headers: { - 'Content-Type': 'application/json' - } - }) - } - } - - const handleOnClickUpdate = e => { - const body = { - accountNickname: props.details.nickname - } - - fetch(`http://${process.env.HOST}:${process.env.PORT}/update`, { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'Content-Type': 'application/json' - } - }).then(data => { - props.handleOnUpdateAccountResponse(data, props.details.nickname) - }) - } - - return ( -
-
{props.details.nickname}
-
- {status} -
-
- -
-
- -
-
- ) -} - -export default Account diff --git a/src/components/accounts.jsx b/src/components/accounts.jsx deleted file mode 100644 index 9bd8376d..00000000 --- a/src/components/accounts.jsx +++ /dev/null @@ -1,133 +0,0 @@ -import '../styles/style.scss' -import PlaidLink from 'react-plaid-link' -import React from 'react' -import Account from './account' -import { fetch } from './helpers' -import Link from 'next/link' - -class Accounts extends React.Component { - constructor(props) { - super(props) - this.state = { currentAccountNickname: '', accounts: false } - } - - componentDidMount = async () => { - if (this.props.config.PLAID_ENVIRONMENT && this.props.config.PLAID_PUBLIC_KEY) { - this.setState({ accounts: await fetch(`http://${process.env.HOST}:${process.env.PORT}/balances`) }) - } - } - - handleOnNewAccountNameChange = e => { - this.setState({ currentAccountNickname: e.currentTarget.value }) - } - - handleOnSuccess = (public_token, metadata) => { - if (!public_token) { - return - } - const body = { - public_token, - accountNickname: this.state.currentAccountNickname - } - fetch(`http://${process.env.HOST}:${process.env.PORT}/token`, { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'Content-Type': 'application/json' - } - }) - } - - handleOnUpdateAccountResponse = (data, nickname) => { - this.setState({ currentAccountNickname: nickname }, () => - window.Plaid.create({ - clientName: 'Mintable', - env: this.props.config.PLAID_ENVIRONMENT, - product: ['transactions'], - key: this.props.config.PLAID_PUBLIC_KEY, - onExit: this.handleOnExit, - onSuccess: this.handleOnSuccess, - token: data - }).open() - ) - } - - render = () => { - let accountsList, newAccountSetup - - // If there are no accounts, display loading message - if (!this.state.accounts) { - accountsList = null - } - // Render accounts - else if (this.state.accounts && this.state.accounts.length > 0) { - accountsList = ( -
-

Current Accounts

- - Note: In the Plaid Development environment, removing an item will not decrement your live - credential count. - -
- {this.state.accounts.map(account => ( - - ))} -
-
- ) - } - - if (this.props.config.PLAID_ENVIRONMENT && this.props.config.PLAID_PUBLIC_KEY) { - newAccountSetup = ( -
-

Add a New Account

-
- Mintable uses nicknames like 'Chase' or 'Discover' to refer to accounts. Enter a nickname to add a new - account: -
-
- - - Add New Account - -
-
- ) - } else { - newAccountSetup = ( -
- You need to setup your account provider before adding accounts - here. -
- ) - } - - return ( -
- {accountsList} - {newAccountSetup} -
- ) - } -} - -export default Accounts diff --git a/src/components/configPropertyInput.jsx b/src/components/configPropertyInput.jsx deleted file mode 100644 index b006f8c2..00000000 --- a/src/components/configPropertyInput.jsx +++ /dev/null @@ -1,20 +0,0 @@ -import { FiCheckCircle, FiHelpCircle } from 'react-icons/fi' - -const ConfigPropertyInput = props => ( -
- - - {props.displayName} - {props.tooltip && } - - -
-) - -export default ConfigPropertyInput diff --git a/src/components/configPropertyInputGroup.jsx b/src/components/configPropertyInputGroup.jsx deleted file mode 100644 index cb8ffe36..00000000 --- a/src/components/configPropertyInputGroup.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import '../styles/style.scss' -import * as _ from 'lodash' -import { fetch } from './helpers' -import ConfigPropertyInput from './configPropertyInput' -import React from 'react' - -class ConfigPropertyInputGroup extends React.Component { - constructor(props) { - super(props) - this.state = { modifiedProperties: new Set() } - } - - handleInputChange = e => { - const { id, value } = e.currentTarget - const body = { updates: { [id]: value } } - - this.setState( - { - modifiedProperties: this.state.modifiedProperties.add(id) - }, - () => - fetch(`http://${process.env.HOST}:${process.env.PORT}/config`, { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'Content-Type': 'application/json' - } - }) - ) - } - - render() { - const configPropertyInputs = _.map(this.props.configProperties, property => { - return ( - - ) - }) - - return
{configPropertyInputs}
- } -} - -export default ConfigPropertyInputGroup diff --git a/src/components/helpers.js b/src/components/helpers.js deleted file mode 100644 index eb15bb5b..00000000 --- a/src/components/helpers.js +++ /dev/null @@ -1,19 +0,0 @@ -import * as _ from 'lodash' -import f from 'isomorphic-unfetch' - -export const fetch = (url, options) => { - return f(url, options) - .then(response => response.json()) - .then(response => { - if (response.error) { - console.error(`Error fetching ${url}:`) - console.error(response.error) - } else { - if (process.browser) { - console.log(`Successfully fetched ${url}:`) - console.log(response.data) - } - return response.data - } - }) -} diff --git a/src/components/progressItem.jsx b/src/components/progressItem.jsx deleted file mode 100644 index d337d1bb..00000000 --- a/src/components/progressItem.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import Link from 'next/link' -import { FiCheckCircle } from 'react-icons/fi' - -const ProgressItem = props => { - return ( - - - - {props.number}. {props.title} - - - ) -} - -export default ProgressItem diff --git a/src/components/progressSidebar.jsx b/src/components/progressSidebar.jsx deleted file mode 100644 index 749d5122..00000000 --- a/src/components/progressSidebar.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import ProgressItem from './progressItem' -import * as _ from 'lodash' - -const ProgressSidebar = props => { - return ( -
- - - - - -
- ) -} - -export default ProgressSidebar diff --git a/src/integrations/csv-export/csvExportIntegration.ts b/src/integrations/csv-export/csvExportIntegration.ts new file mode 100644 index 00000000..e2d069ea --- /dev/null +++ b/src/integrations/csv-export/csvExportIntegration.ts @@ -0,0 +1,67 @@ +import { Config } from '../../common/config' +import { IntegrationId } from '../../types/integrations' +import { logInfo, logError } from '../../common/logging' +import { Account } from '../../types/account' +import { Transaction } from '../../types/transaction' +import { CSVExportConfig } from '../../types/integrations/csv-export' +import { writeFileSync } from 'fs' +import stringify from 'csv-stringify/lib/sync' +import { format } from 'date-fns' + +export class CSVExportIntegration { + config: Config + CSVExportConfig: CSVExportConfig + + constructor(config: Config) { + this.config = config + this.CSVExportConfig = this.config.integrations[IntegrationId.CSVExport] as CSVExportConfig + } + + public updateTransactions = async (accounts: Account[]) => { + try { + const transactions: Transaction[] = accounts.map(account => account.transactions).flat(10) + + // Format Dates + const output = transactions.map(transaction => ({ + ...transaction, + date: format(transaction.date, this.CSVExportConfig.dateFormat || 'yyyy.MM') + })) + + const data = stringify(output, { + header: true, + columns: this.config.transactions.properties + }) + + writeFileSync(this.CSVExportConfig.transactionPath, data) + + logInfo( + `Successfully exported ${transactions.length} transactions for integration ${IntegrationId.CSVExport}` + ) + + logInfo('You can view your transactions here:\n') + console.log(`${this.CSVExportConfig.transactionPath}`) + } catch (error) { + logError(`Error exporting transactions for integration ${IntegrationId.CSVExport}`, error) + } + } + + public updateBalances = async (accounts: Account[]) => { + try { + const data = stringify(accounts, { + header: true, + columns: this.config.balances.properties + }) + + writeFileSync(this.CSVExportConfig.balancePath, data) + + logInfo( + `Successfully exported ${accounts.length} account balances for integration ${IntegrationId.CSVExport}` + ) + + logInfo('You can view your account balances here:\n') + console.log(`${this.CSVExportConfig.balancePath}`) + } catch (error) { + logError(`Error exporting account balances for integration ${IntegrationId.CSVExport}`, error) + } + } +} diff --git a/src/integrations/csv-export/setup.ts b/src/integrations/csv-export/setup.ts new file mode 100644 index 00000000..d953fef0 --- /dev/null +++ b/src/integrations/csv-export/setup.ts @@ -0,0 +1,71 @@ +import { defaultCSVExportConfig, CSVExportConfig } from '../../types/integrations/csv-export' +import prompts from 'prompts' +import { IntegrationId } from '../../types/integrations' +import { updateConfig } from '../../common/config' +import { logInfo, logError } from '../../common/logging' + +export default async () => { + return new Promise(async (resolve, reject) => { + try { + console.log( + '\nThis script will walk you through setting up the CSV Export integration. Follow these steps:' + ) + console.log('\n\t1. Choose a consistent path on your computer for exported CSV file(s).') + console.log('\t2. Copy the absolute path of this file(s).') + console.log('\t3. Answer the following questions:\n') + + const responses = await prompts([ + { + type: 'text', + name: 'name', + message: 'What would you like to call this integration?', + initial: 'CSV Export', + validate: (s: string) => + 1 < s.length && s.length <= 64 ? true : 'Must be between 2 and 64 characters in length.' + }, + { + type: 'text', + name: 'transactionPath', + message: `Where would you like to save exported transactions?`, + initial: '/path/to/my/transactions.csv', + validate: (s: string) => + s.substring(0, 1) === '/' && s.substring(s.length - 4) === '.csv' + ? true + : 'Must start with `/` and end with `.csv`.' + }, + { + type: 'text', + name: 'balancePath', + message: `Where would you like to save exported account balances?`, + initial: '/path/to/my/account-balances.csv', + validate: (s: string) => + s.substring(0, 1) === '/' && s.substring(s.length - 4) === '.csv' + ? true + : 'Must start with `/` and end with `.csv`.' + } + ]) + + updateConfig(config => { + let CSVExportConfig = + (config.integrations[IntegrationId.CSVExport] as CSVExportConfig) || defaultCSVExportConfig + + CSVExportConfig.name = responses.name + CSVExportConfig.transactionPath = responses.transactionPath + CSVExportConfig.balancePath = responses.balancePath + + config.balances.integration = IntegrationId.CSVExport + config.transactions.integration = IntegrationId.CSVExport + + config.integrations[IntegrationId.CSVExport] = CSVExportConfig + + return config + }) + + logInfo('Successfully set up CSV Export Integration.') + return resolve() + } catch (e) { + logError('Unable to set up CSV Export Integration.', e) + return reject() + } + }) +} diff --git a/src/integrations/csv-import/csvImportIntegration.ts b/src/integrations/csv-import/csvImportIntegration.ts new file mode 100644 index 00000000..7ae9f036 --- /dev/null +++ b/src/integrations/csv-import/csvImportIntegration.ts @@ -0,0 +1,116 @@ +import { Config } from '../../common/config' +import { IntegrationId } from '../../types/integrations' +import { logInfo, logError, logWarn } from '../../common/logging' +import { AccountConfig, Account, CSVAccountConfig } from '../../types/account' +import { Transaction } from '../../types/transaction' +import { CSVImportConfig } from '../../types/integrations/csv-import' +import glob from 'glob' +import { readFileSync } from 'fs' +import parse from 'csv-parse/lib/sync' +import * as dateFns from 'date-fns' + +export class CSVImportIntegration { + config: Config + CSVImportConfig: CSVImportConfig + + constructor(config: Config) { + this.config = config + this.CSVImportConfig = this.config.integrations[IntegrationId.CSVImport] as CSVImportConfig + } + + public fetchAccount = async (accountConfig: AccountConfig, startDate: Date, endDate: Date): Promise => { + return new Promise(async (resolve, reject) => { + const CSVAccountConfig = accountConfig as CSVAccountConfig + + const account: Account = { + account: accountConfig.id, + integration: accountConfig.integration, + transactions: [] + } + + // parse file globs + account.transactions = CSVAccountConfig.paths + .map(path => { + try { + const files = glob.sync(path) + + if (files.length === 0) { + logError(`No files resolved for path glob ${path}.`) + } + + return files.map(match => { + try { + const rows = parse(readFileSync(match), { + columns: true, + skip_empty_lines: true + }) + + const transactions: Transaction[] = rows.map(inputRow => { + const outputRow = {} as Transaction + + Object.keys(CSVAccountConfig.transformer).map(inputColumn => { + // Concatenate multiple columns + if (inputColumn.includes('+')) { + outputRow[CSVAccountConfig.transformer[inputColumn] as string] = inputColumn + .split('+') + .map(c => inputRow[c]) + .join(' - ') + } else { + outputRow[CSVAccountConfig.transformer[inputColumn] as string] = + inputRow[inputColumn] + } + }) + + // Remove spaces/special characters from amount field + if (outputRow.hasOwnProperty('amount')) { + const pattern = new RegExp(`[^0-9\.\-]*`, 'gi') + outputRow['amount'] = parseFloat(outputRow['amount'].toString().replace(pattern, '')) + } + + // Parse dates + if (outputRow.hasOwnProperty('date')) { + outputRow['date'] = dateFns.parse( + outputRow['date'].toString(), + CSVAccountConfig.dateFormat, + new Date() + ) + } + + if (CSVAccountConfig.negateValues === true && outputRow.hasOwnProperty('amount')) { + outputRow['amount'] = -outputRow['amount'] + } + + if (!outputRow.hasOwnProperty('account')) { + outputRow.account = CSVAccountConfig.id + } + + if (!outputRow.hasOwnProperty('pending')) { + outputRow.pending = false + } + + return outputRow + }) + + logInfo(`Successfully imported transactions from ${match}.`) + + return transactions.filter(transaction => { + if (transaction.hasOwnProperty('date')) { + return transaction.date >= startDate && transaction.date <= endDate + } + return true + }) + } catch (e) { + logError(`Error importing transactions from ${match}.`, e) + } + }) + } catch (e) { + logError(`Error resolving path glob ${path}.`, e) + } + }) + .flat(10) + + logInfo(`Successfully imported transactions for integration ${IntegrationId.CSVImport}`, account) + return resolve(account) + }) + } +} diff --git a/src/integrations/csv-import/setup.ts b/src/integrations/csv-import/setup.ts new file mode 100644 index 00000000..5006b22e --- /dev/null +++ b/src/integrations/csv-import/setup.ts @@ -0,0 +1,87 @@ +import { defaultCSVImportConfig, CSVImportConfig } from '../../types/integrations/csv-import' +import prompts from 'prompts' +import { IntegrationId } from '../../types/integrations' +import { updateConfig } from '../../common/config' +import { logInfo, logError } from '../../common/logging' +import { CSVAccountConfig } from '../../types/account' + +export default async () => { + return new Promise(async (resolve, reject) => { + try { + console.log( + '\nThis script will walk you through setting up the CSV Import integration. Follow these steps:' + ) + console.log('\n\t1. Choose a consistent folder on your computer to hold CSV files you want to import.') + console.log('\t2. Copy the absolute path of this folder (globs for multiple files are also supported).') + console.log('\t3. Answer the following questions:\n') + + const responses = await prompts([ + { + type: 'text', + name: 'name', + message: 'What would you like to call this integration?', + initial: 'CSV Import', + validate: (s: string) => + 1 < s.length && s.length <= 64 ? true : 'Must be between 2 and 64 characters in length.' + }, + { + type: 'text', + name: 'account', + message: 'What would you like to call this account?', + initial: 'My CSV Account', + validate: (s: string) => + 1 < s.length && s.length <= 64 ? true : 'Must be between 2 and 64 characters in length.' + }, + { + type: 'text', + name: 'path', + message: "What is the path/globs to the CSV file(s) you'd like to import?", + initial: '/path/to/my/csv/files/*.csv', + validate: (s: string) => (s.substring(0, 1) === '/' ? true : 'Must start with `/`.') + }, + { + type: 'text', + name: 'dateFormat', + message: 'What is the format of the date column in these files?', + initial: 'yyyyMMdd', + validate: (s: string) => + 1 < s.length && s.length <= 64 ? true : 'Must be between 1 and 64 characters in length.' + } + ]) + + const defaultCSVAccountConfig: CSVAccountConfig = { + paths: [responses.path], + transformer: { + name: 'name', + date: 'date', + amount: 'amount' + }, + dateFormat: responses.dateFormat, + id: responses.account, + integration: IntegrationId.CSVImport + } + + updateConfig(config => { + let CSVImportConfig = + (config.integrations[IntegrationId.CSVImport] as CSVImportConfig) || defaultCSVImportConfig + + CSVImportConfig.name = responses.name + + config.integrations[IntegrationId.CSVImport] = CSVImportConfig + config.accounts[responses.account] = defaultCSVAccountConfig + + return config + }) + + console.log( + `\n\t4. Edit the 'transformer' field of the new account added to your ~/mintable.jsonc config file to map the input columns of your CSV file to a supported Transaction column in Mintable.\n` + ) + + logInfo('Successfully set up CSV Import Integration.') + return resolve() + } catch (e) { + logError('Unable to set up CSV Import Integration.', e) + return reject() + } + }) +} diff --git a/src/integrations/google/googleIntegration.ts b/src/integrations/google/googleIntegration.ts new file mode 100644 index 00000000..3529b536 --- /dev/null +++ b/src/integrations/google/googleIntegration.ts @@ -0,0 +1,374 @@ +import { google, sheets_v4 } from 'googleapis' +import { Config, updateConfig } from '../../common/config' +import { IntegrationId } from '../../types/integrations' +import { GoogleConfig } from '../../types/integrations/google' +import { OAuth2Client, Credentials } from 'google-auth-library' +import { logInfo, logError } from '../../common/logging' +import { Account } from '../../types/account' +import { sortBy, groupBy } from 'lodash' +import { startOfMonth, format, formatISO, parseISO } from 'date-fns' + +export interface Range { + sheet: string + start: string + end: string +} + +export interface DataRange { + range: Range + data: any[][] +} + +export class GoogleIntegration { + config: Config + googleConfig: GoogleConfig + client: OAuth2Client + sheets: sheets_v4.Resource$Spreadsheets + + constructor(config: Config) { + this.config = config + this.googleConfig = config.integrations[IntegrationId.Google] as GoogleConfig + + this.client = new google.auth.OAuth2( + this.googleConfig.credentials.clientId, + this.googleConfig.credentials.clientSecret, + this.googleConfig.credentials.redirectUri + ) + + this.client.setCredentials({ + access_token: this.googleConfig.credentials.accessToken, + refresh_token: this.googleConfig.credentials.refreshToken, + token_type: this.googleConfig.credentials.tokenType, + expiry_date: this.googleConfig.credentials.expiryDate + }) + + this.sheets = google.sheets({ + version: 'v4', + auth: this.client + }).spreadsheets + } + + public getAuthURL = (): string => + this.client.generateAuthUrl({ + scope: this.googleConfig.credentials.scope + }) + + public getAccessTokens = (authCode: string): Promise => + this.client.getToken(authCode).then(response => response.tokens) + + public saveAccessTokens = (tokens: Credentials): void => { + updateConfig(config => { + let googleConfig = config.integrations[IntegrationId.Google] as GoogleConfig + + googleConfig.credentials.accessToken = tokens.access_token + googleConfig.credentials.refreshToken = tokens.refresh_token + googleConfig.credentials.tokenType = tokens.token_type + googleConfig.credentials.expiryDate = tokens.expiry_date + + config.integrations[IntegrationId.Google] = googleConfig + + return config + }) + } + + public getSheets = (documentId?: string): Promise => { + return this.sheets + .get({ spreadsheetId: documentId || this.googleConfig.documentId }) + .then(res => { + logInfo(`Fetched ${res.data.sheets.length} sheets.`, res.data.sheets) + return res.data.sheets + }) + .catch(error => { + logError(`Error fetching sheets for spreadsheet ${this.googleConfig.documentId}.`, error) + return [] + }) + } + + public copySheet = async (title: string, sourceDocumentId?: string): Promise => { + const sheets = await this.getSheets(sourceDocumentId || this.googleConfig.documentId) + let sourceSheetId + + try { + sourceSheetId = sheets.find(sheet => sheet.properties.title === title).properties.sheetId + } catch (error) { + logError(`Error finding template sheet ${title} in document ${sourceDocumentId}.`, { error, sheets }) + } + + return this.sheets.sheets + .copyTo({ + spreadsheetId: sourceDocumentId || this.googleConfig.documentId, + sheetId: sourceSheetId, + requestBody: { destinationSpreadsheetId: this.googleConfig.documentId } + }) + .then(res => { + logInfo(`Copied sheet ${title}.`, res.data) + return res.data + }) + .catch(error => { + logError(`Error copying sheet ${title}.`, error) + return {} + }) + } + + public addSheet = (title: string): Promise => { + return this.sheets + .batchUpdate({ + spreadsheetId: this.googleConfig.documentId, + requestBody: { requests: [{ addSheet: { properties: { title } } }] } + }) + .then(res => { + logInfo(`Added sheet ${title}.`, res.data) + return res.data.replies[0].addSheet.properties + }) + .catch(error => { + logError(`Error adding sheet ${title}.`, error) + return {} + }) + } + + public renameSheet = async (oldTitle: string, newTitle: string): Promise => { + const sheets = await this.getSheets() + const sheetId = sheets.find(sheet => sheet.properties.title === oldTitle).properties.sheetId + + return this.sheets + .batchUpdate({ + spreadsheetId: this.googleConfig.documentId, + requestBody: { + requests: [ + { + updateSheetProperties: { + properties: { sheetId: sheetId, title: newTitle }, + fields: 'title' + } + } + ] + } + }) + .then(res => { + logInfo(`Renamed sheet ${oldTitle} to ${newTitle}.`, res.data) + return res.data.replies + }) + .catch(error => { + logError(`Error renaming sheet ${oldTitle} to ${newTitle}.`, error) + return [] + }) + } + + public translateRange = (range: Range): string => + `'${range.sheet}'!${range.start.toUpperCase()}:${range.end.toUpperCase()}` + + public translateRanges = (ranges: Range[]): string[] => ranges.map(this.translateRange) + + public clearRanges = (ranges: Range[]): Promise => { + const translatedRanges = this.translateRanges(ranges) + return this.sheets.values + .batchClear({ + spreadsheetId: this.googleConfig.documentId, + requestBody: { ranges: translatedRanges } + }) + .then(res => { + logInfo(`Cleared ${ranges.length} range(s): ${translatedRanges}.`, res.data) + return res.data + }) + .catch(error => { + logError(`Error clearing ${ranges.length} range(s): ${translatedRanges}.`, error) + return {} + }) + } + + public updateRanges = (dataRanges: DataRange[]): Promise => { + const data = dataRanges.map(dataRange => ({ + range: this.translateRange(dataRange.range), + values: dataRange.data + })) + return this.sheets.values + .batchUpdate({ + spreadsheetId: this.googleConfig.documentId, + requestBody: { + valueInputOption: `USER_ENTERED`, + data: data + } + }) + .then(res => { + logInfo(`Updated ${data.length} range(s): ${data.map(r => r.range)}.`, res.data) + return res.data + }) + .catch(error => { + logError(`Error updating ${data.length} range(s): ${data.map(r => r.range)}.`, error) + return {} + }) + } + + public sortSheets = async (): Promise => { + const sheets = await this.getSheets() + const ordered = sortBy(sheets, sheet => sheet.properties.title).reverse() + + return this.sheets + .batchUpdate({ + spreadsheetId: this.googleConfig.documentId, + requestBody: { + requests: ordered.map((sheet, i) => ({ + updateSheetProperties: { + properties: { sheetId: sheet.properties.sheetId, index: i }, + fields: 'index' + } + })) + } + }) + .then(res => { + logInfo(`Updated indices for ${sheets.length} sheets.`, res.data) + return res.data + }) + .catch(error => { + logError(`Error updating indices for ${sheets.length} sheets.`, error) + return {} + }) + } + + public formatSheets = async (): Promise => { + const sheets = await this.getSheets() + + return this.sheets + .batchUpdate({ + spreadsheetId: this.googleConfig.documentId, + requestBody: { + requests: sheets + .map(sheet => [ + { + repeatCell: { + range: { sheetId: sheet.properties.sheetId, startRowIndex: 0, endRowIndex: 1 }, + cell: { + userEnteredFormat: { + backgroundColor: { red: 0.2, green: 0.2, blue: 0.2 }, + horizontalAlignment: 'CENTER', + textFormat: { + foregroundColor: { red: 1.0, green: 1.0, blue: 1.0 }, + bold: true + } + } + }, + fields: 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)' + } + }, + { + updateSheetProperties: { + properties: { + sheetId: sheet.properties.sheetId, + gridProperties: { frozenRowCount: 1 } + }, + fields: 'gridProperties.frozenRowCount' + } + }, + { + autoResizeDimensions: { + dimensions: { + sheetId: sheet.properties.sheetId, + dimension: 'COLUMNS', + startIndex: 0, + endIndex: sheet.properties.gridProperties.columnCount + } + } + } + ]) + .flat(10) + } + }) + .then(res => { + logInfo(`Updated formatting for ${sheets.length} sheets.`, res.data) + return res.data + }) + .catch(error => { + logError(`Error updating formatting for ${sheets.length} sheets.`, error) + return {} + }) + } + + public getRowWithDefaults = (row: { [key: string]: any }, columns: string[], defaultValue: any = null): any[] => { + return columns.map(key => { + if (row && row.hasOwnProperty(key)) { + if (key === 'date') { + return format(row[key], this.googleConfig.dateFormat || 'yyyy.MM.dd') + } + return row[key] + } + return defaultValue + }) + } + + public updateSheet = async ( + sheetTitle: string, + rows: { [key: string]: any }[], + columns?: string[], + useTemplate?: boolean + ): Promise => { + const sheets = await this.getSheets() + const existing = sheets.find(sheet => sheet.properties.title === sheetTitle) + + if (existing === undefined) { + if (this.googleConfig.template && useTemplate === true) { + const copied = await this.copySheet( + this.googleConfig.template.sheetTitle, + this.googleConfig.template.documentId + ) + await this.renameSheet(copied.title, sheetTitle) + } else { + await this.addSheet(sheetTitle) + } + } + + columns = columns || Object.keys(rows[0]) + + const columnHeaders = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') + + const range = { + sheet: sheetTitle, + start: `A1`, + end: `${columnHeaders[columns.length > 0 ? columns.length - 1 : 1]}${rows.length + 1}` + } + const data = [columns].concat(rows.map(row => this.getRowWithDefaults(row, columns))) + + await this.clearRanges([range]) + return this.updateRanges([{ range, data }]) + } + + public updateTransactions = async (accounts: Account[]) => { + // Sort transactions by date + const transactions = sortBy(accounts.map(account => account.transactions).flat(10), 'date') + + // Split transactions by month + const groupedTransactions = groupBy(transactions, transaction => formatISO(startOfMonth(transaction.date))) + + // Write transactions by month, copying template sheet if necessary + for (const month in groupedTransactions) { + await this.updateSheet( + format(parseISO(month), this.googleConfig.dateFormat || 'yyyy.MM'), + groupedTransactions[month], + this.config.transactions.properties, + true + ) + } + + // Sort Sheets + await this.sortSheets() + + // Format, etc. + await this.formatSheets() + + logInfo('You can view your sheet here:\n') + console.log(`https://docs.google.com/spreadsheets/d/${this.googleConfig.documentId}`) + } + + public updateBalances = async (accounts: Account[]) => { + // Update Account Balances Sheets + await this.updateSheet('Balances', accounts, this.config.balances.properties) + + // Sort Sheets + await this.sortSheets() + + // Format, etc. + await this.formatSheets() + + logInfo('You can view your sheet here:\n') + console.log(`https://docs.google.com/spreadsheets/d/${this.googleConfig.documentId}`) + } +} diff --git a/src/integrations/google/setup.ts b/src/integrations/google/setup.ts new file mode 100644 index 00000000..f1352b17 --- /dev/null +++ b/src/integrations/google/setup.ts @@ -0,0 +1,96 @@ +import { GoogleConfig, defaultGoogleConfig } from '../../types/integrations/google' +import { updateConfig, getConfig } from '../../common/config' +import prompts from 'prompts' +import { IntegrationId } from '../../types/integrations' +import open from 'open' +import { GoogleIntegration } from './googleIntegration' +import { logInfo, logError } from '../../common/logging' + +export default async () => { + return new Promise(async (resolve, reject) => { + try { + console.log( + '\nThis script will walk you through setting up the Google Sheets integration. Follow these steps:' + ) + console.log('\n\t1. Create a new Google Sheet (https://sheets.new)') + console.log('\t2. Visit https://developers.google.com/sheets/api/quickstart/nodejs') + console.log("\t3. Click 'Enable the Google Sheets API, select 'Desktop App', and click 'Create'") + console.log('\t4. Answer the following questions:\n') + + const credentials = await prompts([ + { + type: 'text', + name: 'name', + message: 'What would you like to call this integration?', + initial: 'Google Sheets', + validate: (s: string) => + 0 < s.length && s.length <= 64 ? true : 'Must be between 0 and 64 characters in length.' + }, + { + type: 'password', + name: 'clientId', + message: 'Client ID', + validate: (s: string) => (s.length >= 8 ? true : 'Must be at least 8 characters in length.') + }, + { + type: 'password', + name: 'clientSecret', + message: 'Client Secret', + validate: (s: string) => (s.length >= 8 ? true : 'Must be at least 8 characters in length.') + }, + { + type: 'text', + name: 'documentId', + message: + 'Document ID (From the sheet you just created: https://docs.google.com/spreadsheets/d/DOCUMENT_ID/edit)', + validate: (s: string) => (s.length >= 8 ? true : 'Must be at least 8 characters in length.') + } + ]) + + updateConfig(config => { + let googleConfig = (config.integrations[IntegrationId.Google] as GoogleConfig) || defaultGoogleConfig + + googleConfig.name = credentials.name + googleConfig.documentId = credentials.documentId + googleConfig.credentials.clientId = credentials.clientId + googleConfig.credentials.clientSecret = credentials.clientSecret + + config.integrations[IntegrationId.Google] = googleConfig + + config.transactions.integration = IntegrationId.Google + config.balances.integration = IntegrationId.Google + + return config + }) + + const google = new GoogleIntegration(getConfig()) + open(google.getAuthURL()) + + console.log('\n\t5. A link will open in your browser asking you to sign in') + console.log('\t6. Sign in with the account you want to use with Mintable') + console.log( + "\t7. If you see a page saying 'This app isn't verified', click 'Advanced' and then 'Go to app (unsafe)'" + ) + console.log("\t8. Click 'Allow' on both of the next two screens") + console.log('\t9. Copy & paste the code from your browser below:\n') + + const authentication = await prompts([ + { + type: 'password', + name: 'code', + message: 'Enter the code from your browser here', + validate: (s: string) => (s.length >= 8 ? true : 'Must be at least 8 characters in length.') + } + ]) + + const tokens = await google.getAccessTokens(authentication.code) + await google.saveAccessTokens(tokens) + + logInfo('Successfully set up Google Integration.') + return resolve() + } catch (e) { + logError('Unable to set up Plaid Integration.', e) + return reject() + } + }) +} diff --git a/src/integrations/plaid/account-setup.html b/src/integrations/plaid/account-setup.html new file mode 100644 index 00000000..5f031a31 --- /dev/null +++ b/src/integrations/plaid/account-setup.html @@ -0,0 +1,96 @@ + + + + + +
+ +

Mintable

+

Plaid Account Setup

+
+ + + +
+ + +
+ + + + + + + diff --git a/src/integrations/plaid/accountSetup.ts b/src/integrations/plaid/accountSetup.ts new file mode 100644 index 00000000..9a08dd62 --- /dev/null +++ b/src/integrations/plaid/accountSetup.ts @@ -0,0 +1,31 @@ +import { getConfig } from '../../common/config' +import { logInfo, logError } from '../../common/logging' +import open from 'open' +import { PlaidIntegration } from './plaidIntegration' +import { IntegrationId } from '../../types/integrations' +import { PlaidConfig } from '../../types/integrations/plaid' + +export default async () => { + return new Promise(async (resolve, reject) => { + try { + console.log('\nThis script will help you add accounts to Plaid.\n') + console.log('\n\t1. A page will open in your browser allowing you to link accounts with Plaid.') + console.log('\t2. Sign in with your banking provider for each account you wish to link.') + console.log("\t3. Click 'Done Linking Accounts' in your browser when you are finished.\n") + + const config = getConfig() + const plaidConfig = config.integrations[IntegrationId.Plaid] as PlaidConfig + const plaid = new PlaidIntegration(config) + + logInfo('Account setup in progress.') + open(`http://localhost:8000?environment=${plaidConfig.environment}`) + await plaid.accountSetup() + + logInfo('Successfully set up Plaid Account(s).') + return resolve() + } catch (e) { + logError('Unable to set up Plaid Account(s).', e) + return reject() + } + }) +} diff --git a/src/integrations/plaid/plaidIntegration.ts b/src/integrations/plaid/plaidIntegration.ts new file mode 100644 index 00000000..c3700a26 --- /dev/null +++ b/src/integrations/plaid/plaidIntegration.ts @@ -0,0 +1,267 @@ +import path from 'path' +import { parseISO, format, subMonths } from 'date-fns' +import plaid, { TransactionsResponse, CreateLinkTokenOptions } from 'plaid' +import { Config, updateConfig } from '../../common/config' +import { PlaidConfig, PlaidEnvironmentType } from '../../types/integrations/plaid' +import { IntegrationId } from '../../types/integrations' +import express from 'express' +import bodyParser from 'body-parser' +import { logInfo, logError, logWarn } from '../../common/logging' +import http from 'http' +import { AccountConfig, Account, PlaidAccountConfig } from '../../types/account' +import { Transaction } from '../../types/transaction' + +const PLAID_USER_ID = 'LOCAL' + +export class PlaidIntegration { + config: Config + plaidConfig: PlaidConfig + environment: string + client: plaid.Client + user: plaid.User + + constructor(config: Config) { + this.config = config + this.plaidConfig = this.config.integrations[IntegrationId.Plaid] as PlaidConfig + + this.environment = + this.plaidConfig.environment === PlaidEnvironmentType.Development + ? plaid.environments.development + : plaid.environments.sandbox + + this.client = new plaid.Client({ + clientID: this.plaidConfig.credentials.clientId, + secret: this.plaidConfig.credentials.secret, + env: this.environment, + options: { + version: '2019-05-29' + } + }) + + // In production this is supposed to be a unique identifier but for Mintable we only have one user (you) + this.user = { + client_user_id: PLAID_USER_ID + } + } + + public exchangeAccessToken = (accessToken: string): Promise => + // Exchange an expired API access_token for a new Link public_token + this.client.createPublicToken(accessToken).then(token => token.public_token) + + public savePublicToken = (tokenResponse: plaid.TokenResponse): void => { + updateConfig(config => { + config.accounts[tokenResponse.item_id] = { + id: tokenResponse.item_id, + integration: IntegrationId.Plaid, + token: tokenResponse.access_token + } + this.config = config + return config + }) + } + + public accountSetup = (): Promise => { + return new Promise((resolve, reject) => { + const client = this.client + const app = express() + .use(bodyParser.json()) + .use(bodyParser.urlencoded({ extended: true })) + .use(express.static(path.resolve(path.join(__dirname, '../../../docs')))) + + let server: http.Server + + app.post('/get_access_token', (req, res) => { + if (req.body.public_token !== undefined) { + client.exchangePublicToken(req.body.public_token, (error, tokenResponse) => { + if (error != null) { + reject(logError('Encountered error exchanging Plaid public token.', error)) + } + this.savePublicToken(tokenResponse) + resolve(logInfo('Plaid access token saved.', req.body)) + }) + } else if (req.body.exit !== undefined) { + resolve(logInfo('Plaid authentication exited.', req.body)) + } else { + if ((req.body.error['error-code'] = 'item-no-error')) { + resolve(logInfo('Account is OK, no further action is required.', req.body)) + } else { + reject(logError('Encountered error during authentication.', req.body)) + } + } + return res.json({}) + }) + + app.post('/accounts', async (req, res) => { + let accounts: { name: string; token: string }[] = [] + + for (const accountId in this.config.accounts) { + const accountConfig: PlaidAccountConfig = this.config.accounts[accountId] as PlaidAccountConfig + if (accountConfig.integration === IntegrationId.Plaid) { + try { + await this.client.getAccounts(accountConfig.token).then(resp => { + accounts.push({ + name: resp.accounts[0].name, + token: accountConfig.token + }) + }) + } catch { + accounts.push({ + name: 'Error fetching account name', + token: accountConfig.token + }) + } + } + } + return res.json(accounts) + }) + + app.post('/create_link_token', async (req, res) => { + const clientUserId = this.user.client_user_id + const options: CreateLinkTokenOptions = { + user: { + client_user_id: clientUserId + }, + client_name: 'Mintable', + products: ['transactions'], + country_codes: ['US'], // TODO + language: 'en' // TODO + } + if (req.body.access_token) { + options.access_token = req.body.access_token + delete options.products + } + this.client.createLinkToken(options, (err, data) => { + if (err) { + logError('Error creating Plaid link token.', err) + } + logInfo('Successfully created Plaid link token.') + res.json({ link_token: data.link_token }) + }) + }) + + app.post('/remove', async (req, res) => { + try { + await updateConfig(config => { + Object.values(config.accounts).forEach(account => { + const accountConfig: PlaidAccountConfig = account as PlaidAccountConfig + + if (accountConfig.hasOwnProperty('token') && accountConfig.token == req.body.token) { + delete config.accounts[accountConfig.id] + } + }) + this.config = config + return config + }) + logInfo('Successfully removed Plaid account.', req.body.token) + return res.json({}) + } catch (error) { + logError('Error removing Plaid account.', error) + } + }) + + app.post('/done', (req, res) => { + res.json({}) + return server.close() + }) + + app.get('/', (req, res) => + res.sendFile(path.resolve(path.join(__dirname, '../../../src/integrations/plaid/account-setup.html'))) + ) + + server = require('http') + .createServer(app) + .listen('8000') + }) + } + + public fetchPagedTransactions = async ( + accountConfig: AccountConfig, + startDate: Date, + endDate: Date + ): Promise => { + return new Promise(async (resolve, reject) => { + accountConfig = accountConfig as PlaidAccountConfig + try { + const dateFormat = 'yyyy-MM-dd' + const start = format(startDate, dateFormat) + const end = format(endDate, dateFormat) + + let options: plaid.TransactionsRequestOptions = { count: 500, offset: 0 } + let accounts = await this.client.getTransactions(accountConfig.token, start, end, options) + + while (accounts.transactions.length < accounts.total_transactions) { + options.offset += options.count + const next_page = await this.client.getTransactions(accountConfig.token, start, end, options) + accounts.transactions = accounts.transactions.concat(next_page.transactions) + } + + return resolve(accounts) + } catch (e) { + return reject(e) + } + }) + } + + public fetchAccount = async (accountConfig: AccountConfig, startDate: Date, endDate: Date): Promise => { + if (startDate < subMonths(new Date(), 5)) { + logWarn('Transaction history older than 6 months may not be available for some institutions.', {}) + } + + return this.fetchPagedTransactions(accountConfig, startDate, endDate) + .then(data => { + let accounts: Account[] = data.accounts.map(account => ({ + integration: IntegrationId.Plaid, + accountId: account.account_id, + mask: account.mask, + institution: account.name, + account: account.official_name, + type: account.subtype || account.type, + current: account.balances.current, + available: account.balances.available, + limit: account.balances.limit, + currency: account.balances.iso_currency_code || account.balances.unofficial_currency_code + })) + + const transactions: Transaction[] = data.transactions.map(transaction => ({ + integration: IntegrationId.Plaid, + name: transaction.name, + date: parseISO(transaction.date), + amount: transaction.amount, + currency: transaction.iso_currency_code || transaction.unofficial_currency_code, + type: transaction.transaction_type, + accountId: transaction.account_id, + transactionId: transaction.transaction_id, + category: transaction.category.join(' - '), + address: transaction.location.address, + city: transaction.location.city, + state: transaction.location.region, + postal_code: transaction.location.postal_code, + country: transaction.location.country, + latitude: transaction.location.lat, + longitude: transaction.location.lon, + pending: transaction.pending + })) + + accounts = accounts.map(account => ({ + ...account, + transactions: transactions + .filter(transaction => transaction.accountId === account.accountId) + .map(transaction => ({ + ...transaction, + institution: account.institution, + account: account.account + })) + })) + + logInfo( + `Fetched ${data.accounts.length} sub-accounts and ${data.total_transactions} transactions.`, + accounts + ) + return accounts + }) + .catch(error => { + logError(`Error fetching account ${accountConfig.id}.`, error) + return [] + }) + } +} diff --git a/src/integrations/plaid/setup.ts b/src/integrations/plaid/setup.ts new file mode 100644 index 00000000..e39862f5 --- /dev/null +++ b/src/integrations/plaid/setup.ts @@ -0,0 +1,83 @@ +import { PlaidEnvironmentType, PlaidConfig, defaultPlaidConfig } from '../../types/integrations/plaid' +import { updateConfig } from '../../common/config' +import { IntegrationId } from '../../types/integrations' +import prompts from 'prompts' +import { logInfo, logError } from '../../common/logging' + +export default async () => { + return new Promise(async (resolve, reject) => { + try { + console.log('\nThis script will walk you through setting up the Plaid integration. Follow these steps:') + console.log('\n\t1. Visit https://plaid.com') + console.log("\t2. Click 'Get API Keys'") + console.log('\t3. Fill out the form and wait a few days') + console.log('\t4. Once approved, visit https://dashboard.plaid.com/team/keys') + console.log('\t5. Answer the following questions:\n') + + // @types/prompts needs updated to support choice descriptions + interface ChoiceWithDescription extends prompts.Choice { + description: string + } + + const credentials = await prompts([ + { + type: 'text', + name: 'name', + message: 'What would you like to call this integration?', + initial: 'Plaid', + validate: (s: string) => + 1 < s.length && s.length <= 64 ? true : 'Must be between 2 and 64 characters in length.' + }, + { + type: 'select', + name: 'environment', + message: 'Which Plaid environment would you like to use?', + choices: [ + { + title: 'Sandbox', + description: 'Test credentials for development purposes (unlimited)', + value: PlaidEnvironmentType.Sandbox + }, + { + title: 'Development', + description: 'Real credentials to financial institutions (limited to 100 Items)', + value: PlaidEnvironmentType.Development + } + ] as ChoiceWithDescription[], + initial: 0 + }, + { + type: 'password', + name: 'clientId', + message: 'Client ID', + validate: (s: string) => (s.length === 24 ? true : 'Must be 24 characters in length.') + }, + { + type: 'password', + name: 'secret', + message: "Secret (pick the one corresponding to your 'Environment' choice above)", + validate: (s: string) => (s.length === 30 ? true : 'Must be 30 characters in length.') + } + ]) + + updateConfig(config => { + let plaidConfig = (config.integrations[IntegrationId.Plaid] as PlaidConfig) || defaultPlaidConfig + + plaidConfig.name = credentials.name + plaidConfig.environment = credentials.environment + plaidConfig.credentials.clientId = credentials.clientId + plaidConfig.credentials.secret = credentials.secret + + config.integrations[IntegrationId.Plaid] = plaidConfig + + return config + }) + + logInfo('Successfully set up Plaid Integration.') + return resolve() + } catch (e) { + logError('Unable to set up Plaid Integration.', e) + return reject() + } + }) +} diff --git a/src/lib/common.js b/src/lib/common.js deleted file mode 100644 index f459dfe4..00000000 --- a/src/lib/common.js +++ /dev/null @@ -1,163 +0,0 @@ -const fs = require('fs') -const _ = require('lodash') -const path = require('path') -const { wrapPromise } = require('./logging') - -const CONFIG_FILE = path.join(__dirname, '../..', process.argv[2] || 'mintable.config.json') - -console.log(`\nUsing config ${CONFIG_FILE}.`) -console.log(`Note: The messages displayed below are automated and may contain duplicates.\n`) - -const DEFAULT_CONFIG = { - HOST: 'localhost', - PORT: 3000, - - BALANCE_COLUMNS: ['name', 'official_name', 'type', 'balances.available', 'balances.current', 'balances.limit'], - TRANSACTION_COLUMNS: ['date', 'amount', 'name', 'account', 'category.0', 'category.1', 'pending'], - REFERENCE_COLUMNS: ['notes', 'work', 'joint'], - - ACCOUNT_PROVIDER: 'plaid', - PLAID_ENVIRONMENT: 'development', - CATEGORY_OVERRIDES: [], - - SHEET_PROVIDER: 'sheets', - SHEETS_REDIRECT_URI: `http://localhost:3000/google-sheets-oauth2callback`, - TEMPLATE_SHEET: { - SHEET_ID: '10fYhPJzABd8KlgAzxtiyFN-L_SebTvM8SaAK_wHk-Fw', - SHEET_TITLE: 'Template' - } -} - -const checkEnv = propertyIds => { - const values = _.values(_.pick(process.env, propertyIds)) - return values.length === propertyIds.length && _.every(values, v => v.length) -} - -const getAccountTokens = () => { - if (!process.env) { - return [] - } - - switch (process.env.ACCOUNT_PROVIDER) { - case 'plaid': - return Object.keys(process.env) - .filter(key => key.startsWith(`PLAID_TOKEN`)) - .map(key => ({ - nickname: key.replace(/^PLAID_TOKEN_/, ''), - token: process.env[key] - })) - default: - return [] - } -} - -const accountSetupComplete = () => getAccountTokens().length > 0 - -const accountProviderSetupComplete = () => { - if (!process.env) { - return false - } - - switch (process.env.ACCOUNT_PROVIDER) { - case 'plaid': - return checkEnv(['PLAID_CLIENT_ID', 'PLAID_PUBLIC_KEY', 'PLAID_SECRET']) - default: - return false - } -} - -const sheetProviderSetupComplete = () => { - if (!process.env) { - return false - } - - switch (process.env.SHEET_PROVIDER) { - case 'sheets': - return checkEnv([ - 'SHEETS_SHEET_ID', - 'SHEETS_CLIENT_ID', - 'SHEETS_CLIENT_SECRET', - 'SHEETS_REDIRECT_URI', - 'SHEETS_ACCESS_TOKEN' - ]) - default: - return false - } -} - -const getConfigEnv = async options => - wrapPromise( - new Promise((resolve, reject) => { - let config = process.env.MINTABLE_CONFIG || fs.readFileSync(CONFIG_FILE, 'utf8') - - if (typeof config === 'string') { - config = JSON.parse(config) - } - - process.env = { ...process.env, ...config } - resolve(config) - }), - 'Fetching current config', - options - ) - -const sanitizeConfig = config => { - // recurse configuration objects and arrays to remove whitespace - const sanitize = (chunk) => { - return _.reduce(chunk, (result, v, k) => { - if (_.isObject(v) || _.isArray(v)) { - result[k] = sanitize(v); - } else if (_.isString()) { - result[k] = _.trim(v); - } else { - result[k] = v; - } - - return result; - }, _.isArray(chunk) ? [] : {}) - } - - return sanitize(config); -} - -const writeConfig = async newConfig => - wrapPromise( - new Promise(async (resolve, reject) => { - fs.writeFileSync(CONFIG_FILE, JSON.stringify(sanitizeConfig(newConfig), null, 2)) - resolve(getConfigEnv()) - }), - 'Writing config' - ) - -const updateConfig = async updates => { - const currentConfig = await getConfigEnv() - const newConfig = { ...currentConfig, ...updates } - return wrapPromise(writeConfig(newConfig), `Updating config properties ${_.join(_.keys(updates), ', ')}`) -} - -const deleteConfigProperty = async propertyId => { - delete process.env[propertyId] - const newConfig = _.omit(await getConfigEnv(), [propertyId]) - return wrapPromise(writeConfig(newConfig), `Deleting config property ${propertyId}`) -} - -const maybeWriteDefaultConfig = async () => { - return wrapPromise( - getConfigEnv({ quiet: true }) - .then(currentConfig => writeConfig({ ...DEFAULT_CONFIG, ...currentConfig })) - .catch(writeConfig({ ...DEFAULT_CONFIG })), - 'Writing default config' - ) -} - -module.exports = { - getConfigEnv, - updateConfig, - deleteConfigProperty, - writeConfig, - maybeWriteDefaultConfig, - getAccountTokens, - accountSetupComplete, - accountProviderSetupComplete, - sheetProviderSetupComplete -} diff --git a/src/lib/google.js b/src/lib/google.js deleted file mode 100644 index adc92436..00000000 --- a/src/lib/google.js +++ /dev/null @@ -1,250 +0,0 @@ -const { google } = require('googleapis') -const _ = require('lodash') -const { wrapPromise } = require('./logging') -const { updateConfig } = require('./common') -const pEachSeries = require('p-each-series') - -const OAUTH2_CLIENT = new google.auth.OAuth2( - process.env.SHEETS_CLIENT_ID, - process.env.SHEETS_CLIENT_SECRET, - process.env.SHEETS_REDIRECT_URI -) - -OAUTH2_CLIENT.setCredentials({ - access_token: process.env.SHEETS_ACCESS_TOKEN, - refresh_token: process.env.SHEETS_REFRESH_TOKEN, - scope: process.env.SHEETS_SCOPE, - token_type: process.env.SHEETS_TOKEN_TYPE, - expiry_date: process.env.SHEETS_EXPIRY_DATE -}) - -const sheets = google.sheets({ - version: 'v4', - auth: OAUTH2_CLIENT -}) - -const promisify = (f, args) => { - if (process.env.DEBUG) { - console.log(JSON.stringify(args, null, 2)) - } - return new Promise((resolve, reject) => f(args, (error, data) => (error ? reject(error) : resolve(data)))) -} - -const getAuthURL = () => - wrapPromise( - new Promise((resolve, reject) => { - resolve( - OAUTH2_CLIENT.generateAuthUrl({ - access_type: 'offline', - scope: ['https://www.googleapis.com/auth/spreadsheets'], - client_id: process.env.SHEETS_CLIENT_ID, - redirect_uri: process.env.SHEETS_REDIRECT_URI - }) - ) - }), - 'Fetching Google Sheets Auth URL' - ) - -const getToken = code => - wrapPromise( - OAUTH2_CLIENT.getToken(code).then(res => - updateConfig(_.mapKeys(res.tokens, (value, key) => `SHEETS_${key.toUpperCase()}`)) - ), - `Fetching token for code ${code}` - ) - -const getSheets = spreadsheetId => - wrapPromise( - promisify(sheets.spreadsheets.get, { spreadsheetId: spreadsheetId }).then(res => res.data.sheets), - `Fetching sheets for spreadsheet ID ${spreadsheetId}` - ) - -const duplicateSheet = (sourceSpreadsheetId, sourceSheetId) => - wrapPromise( - promisify(sheets.spreadsheets.sheets.copyTo, { - spreadsheetId: sourceSpreadsheetId, - sheetId: sourceSheetId, - resource: { destinationSpreadsheetId: process.env.SHEETS_SHEET_ID } - }).then(res => ({ properties: res.data })), - `Duplicating sheet ${sourceSheetId}` - ) - -const addSheet = title => - wrapPromise( - promisify(sheets.spreadsheets.batchUpdate, { - spreadsheetId: process.env.SHEETS_SHEET_ID, - resource: { requests: [{ addSheet: { properties: { title } } }] } - }).then(res => res.data.replies[0].addSheet), - `Creating new sheet ${title}` - ) - -const renameSheet = (sheetId, title) => - wrapPromise( - promisify(sheets.spreadsheets.batchUpdate, { - spreadsheetId: process.env.SHEETS_SHEET_ID, - resource: { - requests: [{ updateSheetProperties: { properties: { sheetId: sheetId, title: title }, fields: 'title' } }] - } - }).then(res => res.data), - `Renaming sheet ${title}` - ) - -const clearRanges = ranges => { - return wrapPromise( - promisify(sheets.spreadsheets.values.batchClear, { spreadsheetId: process.env.SHEETS_SHEET_ID, ranges }), - `Clearing ranges ${ranges.join(', ')}` - ) -} - -const updateRanges = updatedRanges => - wrapPromise( - promisify(sheets.spreadsheets.values.batchUpdate, { - spreadsheetId: process.env.SHEETS_SHEET_ID, - resource: { - valueInputOption: `USER_ENTERED`, - data: updatedRanges - } - }), - `Updating cell ranges ${_.map(updatedRanges, d => d.range).join(', ')}` - ) - -const formatSheets = (sheetIds, numColumnsToResize) => - wrapPromise( - promisify(sheets.spreadsheets.batchUpdate, { - spreadsheetId: process.env.SHEETS_SHEET_ID, - resource: { - requests: _.flatten( - _.map(sheetIds, sheetId => [ - { - repeatCell: { - range: { sheetId: sheetId, startRowIndex: 0, endRowIndex: 1 }, - cell: { - userEnteredFormat: { - backgroundColor: { red: 0.3, green: 0.3, blue: 0.3 }, - horizontalAlignment: 'CENTER', - textFormat: { foregroundColor: { red: 1.0, green: 1.0, blue: 1.0 }, fontSize: 12, bold: true } - } - }, - fields: 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)' - } - }, - { - updateSheetProperties: { - properties: { sheetId: sheetId, gridProperties: { frozenRowCount: 1 } }, - fields: 'gridProperties.frozenRowCount' - } - }, - { - autoResizeDimensions: { - dimensions: { sheetId: sheetId, dimension: 'COLUMNS', startIndex: 0, endIndex: numColumnsToResize } - } - } - ]) - ) - } - }), - `Formatting sheets ${sheetIds.join(', ')}` - ) - -const sortSheets = order => - wrapPromise( - promisify(sheets.spreadsheets.batchUpdate, { - spreadsheetId: process.env.SHEETS_SHEET_ID, - resource: { - requests: _.flatten( - _.map(order, sheetId => [ - { - updateSheetProperties: { - properties: { sheetId: sheetId[0], index: sheetId[1] }, - fields: 'index' - } - } - ]) - ) - } - }), - `Sorting sheets` - ) - -const updateSheets = async (updates, options) => { - const { - firstTransactionColumn, - lastTransactionColumn, - firstReferenceColumn, - lastReferenceColumn, - numAutomatedColumns - } = options - - let sheets = await getSheets(process.env.SHEETS_SHEET_ID) - const templateSheet = _.find( - await getSheets(process.env.TEMPLATE_SHEET.SHEET_ID), - sheet => sheet.properties.title === process.env.TEMPLATE_SHEET.SHEET_TITLE - ) - - const currentSheetTitles = _.map(sheets, sheet => sheet.properties.title) - const requiredSheetTitles = _.keys(updates) - - // Create, rename, and clear required sheets - await pEachSeries(_.difference(requiredSheetTitles, currentSheetTitles), async title => { - const newSheet = await duplicateSheet(process.env.TEMPLATE_SHEET.SHEET_ID, templateSheet.properties.sheetId) - await renameSheet(newSheet.properties.sheetId, title) - }) - - // Clear automated sheet ranges - await clearRanges(_.map(requiredSheetTitles, title => `${title}!${firstTransactionColumn}:${lastTransactionColumn}`)) - - let updatedRanges = [] - - _.forIn(updates, (transactions, sheetTitle) => { - // Map transactions to ranges & values - updatedRanges.push({ - range: `${sheetTitle}!${firstTransactionColumn}${2}:${lastTransactionColumn}${transactions.length + 1}`, - values: _.map(transactions, transaction => _.at(transaction, process.env.TRANSACTION_COLUMNS)) - }) - - // Column headers for transaction data - updatedRanges.push({ - range: `${sheetTitle}!${firstTransactionColumn}1:${lastTransactionColumn}1`, - values: [process.env.TRANSACTION_COLUMNS] - }) - - // Additional user-defined reference column headers (specify in .env) - updatedRanges.push({ - range: `${sheetTitle}!${firstReferenceColumn}1:${lastReferenceColumn}1`, - values: [process.env.REFERENCE_COLUMNS] - }) - }) - - await updateRanges(updatedRanges) - - // Format header rows & resize columns - const sheetIds = _.map( - _.pickBy(await getSheets(process.env.SHEETS_SHEET_ID), sheet => - _.includes(requiredSheetTitles, sheet.properties.title) - ), - sheet => sheet.properties.sheetId - ) - - await formatSheets(sheetIds, numAutomatedColumns) - - const sorted = _.map( - _.reverse(_.sortBy(await getSheets(process.env.SHEETS_SHEET_ID), sheet => sheet.properties.title)), - (sheet, i) => [sheet.properties.sheetId, i] - ) - await sortSheets(sorted) - - console.log(`\nView your spreadsheet at https://docs.google.com/spreadsheets/d/${process.env.SHEETS_SHEET_ID}\n`) -} - -module.exports = { - getAuthURL, - getToken, - getSheets, - duplicateSheet, - addSheet, - renameSheet, - clearRanges, - updateRanges, - updateSheets, - formatSheets -} diff --git a/src/lib/logging.js b/src/lib/logging.js deleted file mode 100644 index a9dc4ef0..00000000 --- a/src/lib/logging.js +++ /dev/null @@ -1,53 +0,0 @@ -const ora = require('ora') -const logSymbols = require('log-symbols') -const indentString = require('indent-string') -const { inspect } = require('util') - -const paddedJSON = data => { - return indentString(`\n\n${inspect(data)}\n`, 4) -} - -const defaultOptions = { - debug: false, // Print success output (default: only error output printed) - quiet: false // Resolve even on failures (default: reject on failures) -} - -const wrapPromise = async (promise, text, options = defaultOptions) => { - const spinner = ora({ text: text + '...', indent: 2 }).start() - - return new Promise(async (resolve, reject) => { - let text = spinner.text.replace('...', '') - return promise - .then(data => { - spinner.stopAndPersist({ - text: options.debug || process.env.DEBUG ? text + paddedJSON(data) : text, - symbol: logSymbols.success - }) - resolve(data) - }) - .catch(error => { - text = `Error ${text.toLowerCase()}:` - let errorJSON = { error: error } - - if (error.message) { - errorJSON = { error: error.toString() } - } - - spinner.stopAndPersist({ - symbol: logSymbols.error, - text: text + paddedJSON(errorJSON) - }) - - if (options.quiet === true) { - resolve() - } else { - reject(errorJSON) - process.exit(1) - } - }) - }) -} - -module.exports = { - wrapPromise -} diff --git a/src/lib/plaid.js b/src/lib/plaid.js deleted file mode 100644 index e30f4986..00000000 --- a/src/lib/plaid.js +++ /dev/null @@ -1,170 +0,0 @@ -const { parse, format } = require('date-fns') -const { updateConfig, getAccountTokens } = require('./common') -const { wrapPromise } = require('./logging') -const pMapSeries = require('p-map-series') -const plaid = require('plaid') -const _ = require('lodash') - -const environment = () => { - switch (process.env.PLAID_ENVIRONMENT) { - case 'sandbox': - return plaid.environments.sandbox - case 'production': - return plaid.environments.production - default: - return plaid.environments.development - } -} - -const PLAID_CLIENT = new plaid.Client( - process.env.PLAID_CLIENT_ID, - process.env.PLAID_SECRET, - process.env.PLAID_PUBLIC_KEY, - environment(), - { - version: '2018-05-22' - } -) - -const fetchTransactions = (startDate, endDate, pageSize, offset) => { - const accounts = getAccountTokens() - - const options = [ - format(startDate, 'YYYY-MM-DD'), - format(endDate, 'YYYY-MM-DD'), - { - count: pageSize, - offset: offset - } - ] - - const fetchTransactionsForAccount = account => { - return wrapPromise( - PLAID_CLIENT.getTransactions(account.token, ...options), - `Fetching transactions for account ${account.nickname}` - ).then(data => ({ - account: account.nickname, - transactions: data.transactions.map(transaction => ({ - ...transaction, - amount: -transaction.amount, - accountNickname: account.nickname - })) - })) - } - - return wrapPromise(pMapSeries(accounts, fetchTransactionsForAccount), 'Fetching transactions for accounts') -} - -const fetchBalances = options => { - const accounts = getAccountTokens() - - const fetchBalanceForAccount = account => { - return wrapPromise( - PLAID_CLIENT.getBalance(account.token) - .then(data => { - return { - ...data, - nickname: account.nickname - } - }) - .catch(error => { - return { nickname: account.nickname, error: JSON.stringify(error, null, 2) } - }), - `Fetching balance for account ${account.nickname}`, - options - ) - } - - return wrapPromise(pMapSeries(accounts, fetchBalanceForAccount), 'Fetching balances for accounts', options) -} - -// Exchange token flow - exchange a Link public_token for an API access_token -const saveAccessToken = (public_token, accountNickname) => { - return wrapPromise( - PLAID_CLIENT.exchangePublicToken(public_token).then(tokenResponse => - updateConfig({ [`PLAID_TOKEN_${accountNickname.toUpperCase()}`]: tokenResponse.access_token }) - ), - `Saving access token for account ${accountNickname}` - ) -} - -// Exchange an expired API access_token for a new Link public_token -const createPublicToken = (access_token, accountNickname) => { - return wrapPromise( - PLAID_CLIENT.createPublicToken(access_token).then(tokenResponse => { - return tokenResponse.public_token - }), - `Creating public token for account ${accountNickname}` - ) -} - -const fetchAllCleanTransactions = async (startDate, endDate, pageSize = 250, offset = 0) => { - let transactions = [] - let count = pageSize - let pageNumber = 0 - - // If we receive a full page of transactions from Plaid, that means there is more data to fetch - while (count === pageSize) { - const result = await fetchTransactions(startDate, endDate, pageSize, pageNumber * pageSize) - const clean = _.flatten(_.map(result, account => account.transactions)) - - transactions = transactions.concat(clean) - - count = clean.length - pageNumber++ - } - - // Parse transaction date string into a Date object and clean up Pending column - transactions = _.map(transactions, transaction => ({ - ...transaction, - date: parse(transaction.date), - pending: transaction.pending === true ? 'y' : '' - })) - - // Handle category overrides defined in config - if (process.env.CATEGORY_OVERRIDES) { - // Handle corner case where this was set before v1.0.0 & scripts/migrate.js double escapes it - categoryOverrides = - typeof process.env.CATEGORY_OVERRIDES === 'string' - ? JSON.parse(process.env.CATEGORY_OVERRIDES) - : process.env.CATEGORY_OVERRIDES - - transactions = _.map(transactions, transaction => { - _.forEach(categoryOverrides, override => { - if (new RegExp(override.pattern, _.get(override, 'flags', '')).test(transaction.name)) { - transaction['category.0'] = _.get(override, 'category.0', '') - transaction['category.1'] = _.get(override, 'category.1', '') - } - }) - return transaction - }) - } - - // Fetch accounts & names - const accounts = _.keyBy(_.flatten(_.map(await fetchBalances(), item => item.accounts)), 'account_id') - - // Join in account details to transactions - transactions = _.map(transactions, transaction => { - const account = accounts[transaction.account_id] - return { - ..._.omit(transaction, ['accountNickname']), - account_details: { - ...account, - official_name: account.official_name, - name: account.name, - nickname: transaction.accountNickname - }, - account: account.official_name || account.name || transaction.accountNickname - } - }) - - return transactions -} - -module.exports = { - fetchBalances, - fetchTransactions, - saveAccessToken, - createPublicToken, - fetchAllCleanTransactions -} diff --git a/src/next.config.js b/src/next.config.js deleted file mode 100644 index 97e74282..00000000 --- a/src/next.config.js +++ /dev/null @@ -1,14 +0,0 @@ -const withSass = require("@zeit/next-sass"); -const webpack = require('webpack') -module.exports = withSass({ - webpack: cfg => { - cfg.plugins.push( - new webpack.DefinePlugin({ - 'process.env.HOST': JSON.stringify(process.env.HOST), - 'process.env.PORT': JSON.stringify(process.env.PORT) - }) - ) - - return cfg - } -}); diff --git a/src/pages/account-provider-setup.jsx b/src/pages/account-provider-setup.jsx deleted file mode 100644 index 3b8b637a..00000000 --- a/src/pages/account-provider-setup.jsx +++ /dev/null @@ -1,65 +0,0 @@ -import '../styles/style.scss' -import ProgressSidebar from '../components/progressSidebar' -import { fetch } from '../components/helpers' -import ConfigPropertyInputGroup from '../components/configPropertyInputGroup' -import Link from 'next/link' - -const AccountProviderSetup = props => { - const configProperties = [ - { - displayName: 'environment', - propertyId: 'PLAID_ENVIRONMENT', - tooltip: - 'Sandbox allows unlimited testing with sample accounts, development is limited to 100 free linked accounts.' - }, - { displayName: 'client_id', propertyId: 'PLAID_CLIENT_ID' }, - { displayName: 'public_key', propertyId: 'PLAID_PUBLIC_KEY' }, - { - displayName: `${props.config.PLAID_ENVIRONMENT ? props.config.PLAID_ENVIRONMENT + '_' : ''}secret`, - propertyId: 'PLAID_SECRET', - tooltip: 'Use the same secret as the environment above, i.e. either your Development Secret or Sandbox Secret.' - } - ] - - return ( -
- -
-
-

Plaid Setup

-
    -
  1. - - Sign up - {' '} - for a Plaid account. -
  2. -
  3. - - Apply - {' '} - for the free, 100-account development plan (takes 1-2 days). -
  4. -
  5. - Once approved,{' '} - - find your API keys - {' '} - and copy them over. - -
  6. - - - -
-
-
-
- ) -} - -AccountProviderSetup.getInitialProps = async function() { - return { config: await fetch(`http://${process.env.HOST}:${process.env.PORT}/config`) } -} - -export default AccountProviderSetup diff --git a/src/pages/account-setup.jsx b/src/pages/account-setup.jsx deleted file mode 100644 index 41ad51bc..00000000 --- a/src/pages/account-setup.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import '../styles/style.scss' -import ProgressSidebar from '../components/progressSidebar' -import { fetch } from '../components/helpers' -import Link from 'next/link' -import Accounts from '../components/accounts' - -const AccountSetup = props => { - return ( -
- -
-
-

Account Setup

- - - - -
-
-
- ) -} - -AccountSetup.getInitialProps = async function() { - return { config: await fetch(`http://${process.env.HOST}:${process.env.PORT}/config`) } -} - -export default AccountSetup diff --git a/src/pages/done.jsx b/src/pages/done.jsx deleted file mode 100644 index d9ad8919..00000000 --- a/src/pages/done.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import '../styles/style.scss' -import Link from 'next/link' -import ProgressSidebar from '../components/progressSidebar' -import { fetch } from '../components/helpers' - -const Done = props => ( -
- -
-

Looks like we're all set here!

- - - -
-
-) - -Done.getInitialProps = async function() { - return { config: await fetch(`http://${process.env.HOST}:${process.env.PORT}/config`) } -} - -export default Done diff --git a/src/pages/settings.jsx b/src/pages/settings.jsx deleted file mode 100644 index bb41d577..00000000 --- a/src/pages/settings.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import '../styles/style.scss' -import { fetch } from '../components/helpers' -const _ = require('lodash') -import ConfigPropertyInputGroup from '../components/configPropertyInputGroup' -import Accounts from '../components/accounts' - -const Settings = props => { - const configProperties = _.map(_.keys(props.config), property => { - return { displayName: property, propertyId: property } - }) - - return ( -
-
-

Accounts

- -

Settings

- - Any settings changed below will automatically update your config file. Some settings may require a server - restart to take effect. - - -
-
- ) -} - -Settings.getInitialProps = async function() { - return { config: await fetch(`http://${process.env.HOST}:${process.env.PORT}/config`) } -} - -export default Settings diff --git a/src/pages/sheet-provider-setup.jsx b/src/pages/sheet-provider-setup.jsx deleted file mode 100644 index 8eb2b760..00000000 --- a/src/pages/sheet-provider-setup.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import '../styles/style.scss' -import ProgressSidebar from '../components/progressSidebar' -import { fetch } from '../components/helpers' -import ConfigPropertyInputGroup from '../components/configPropertyInputGroup' -import Link from 'next/link' - -const SheetProviderSetup = props => { - const handleOnClickAuth = async e => { - const URL = await fetch(`http://${process.env.HOST}:${process.env.PORT}/google-sheets-url`) - console.log(URL) - var win = window.open(URL, '_blank') - win.focus() - } - - const sheetConfigProperties = [{ displayName: 'sheet_id', propertyId: 'SHEETS_SHEET_ID' }] - const configFileProperties = [ - { displayName: 'Client ID', propertyId: 'SHEETS_CLIENT_ID' }, - { displayName: 'Client Secret', propertyId: 'SHEETS_CLIENT_SECRET' } - ] - - return ( -
- -
-
-

Google Sheets Setup

-
    -
  1. - - Create a new Google Sheets spreadsheet - {' '} - and copy over the sheet_id from the URL: -
    - - docs.google.com/spreadsheets/d/ - {'sheet_id'}/edit - - -
  2. -
  3. - Go to the{' '} - - Google Sheets API Quickstart - {' '} - and click Enable the Google Sheets API. -
  4. -
  5. - Follow instructions and copy over the resulting values: - -
  6. -
  7. - Click this button to authorize Mintable to use Google Sheets: -
    - -
  8. - - - -
-
-
-
- ) -} - -SheetProviderSetup.getInitialProps = async function() { - return { config: await fetch(`http://${process.env.HOST}:${process.env.PORT}/config`) } -} - -export default SheetProviderSetup diff --git a/src/pages/welcome.jsx b/src/pages/welcome.jsx deleted file mode 100644 index 49615df7..00000000 --- a/src/pages/welcome.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import '../styles/style.scss' -import Link from 'next/link' -import ProgressSidebar from '../components/progressSidebar' -import { fetch } from '../components/helpers' - -const Welcome = props => { - return ( -
- -
- -

Welcome to Mintable

- - - -
-
- ) -} - -Welcome.getInitialProps = async () => { - return { config: await fetch(`http://${process.env.HOST}:${process.env.PORT}/config`) } -} - -export default Welcome diff --git a/src/scripts/cli.ts b/src/scripts/cli.ts new file mode 100755 index 00000000..0e71a330 --- /dev/null +++ b/src/scripts/cli.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +import prompts from 'prompts' +const chalk = require('chalk') +import { updateConfig, readConfig, getConfigSource } from '../common/config' +import plaid from '../integrations/plaid/setup' +import google from '../integrations/google/setup' +import csvImport from '../integrations/csv-import/setup' +import csvExport from '../integrations/csv-export/setup' +import accountSetup from '../integrations/plaid/accountSetup' +import fetch from './fetch' +import migrate from './migrate' +import { logError } from '../common/logging' +;(async function() { + const logo = [ + '\n', + ' %', + ' %%', + ' %%%%%', + ' %%%%%%%%', + ' %%%%%%%%%%', + ' %%%%%%%%%%%%', + ' %%%% %%%%%%%%', + ' %%% %%%%%%', + ' %% %%%%%%', + ' % %%%', + ' %%%', + ' %%', + ' %', + '\n' + ] + + logo.forEach(line => { + console.log(chalk.green(line)) + }) + + console.log(' M I N T A B L E\n') + + const commands = { + migrate: migrate, + fetch: fetch, + 'plaid-setup': plaid, + 'account-setup': accountSetup, + 'google-setup': google, + 'csv-import-setup': csvImport, + 'csv-export-setup': csvExport + } + + const arg = process.argv[2] + + if (arg == 'setup') { + const configSource = getConfigSource() + if (readConfig(configSource, true)) { + const overwrite = await prompts([ + { + type: 'confirm', + name: 'confirm', + message: 'Config already exists. Do you to overwrite it?', + initial: false + } + ]) + if (overwrite.confirm === false) { + logError('Config update cancelled by user.') + } + } + updateConfig(config => config, true) + await plaid() + await google() + await accountSetup() + } else if (commands.hasOwnProperty(arg)) { + commands[arg]() + } else { + console.log(`\nmintable v${require('../../package.json').version}\n`) + console.log('\nusage: mintable \n') + console.log('available commands:') + Object.keys(commands) + .concat(['setup']) + .forEach(command => console.log(`\t${command}`)) + } +})() diff --git a/src/scripts/export.js b/src/scripts/export.js deleted file mode 100644 index 123d260d..00000000 --- a/src/scripts/export.js +++ /dev/null @@ -1,13 +0,0 @@ -const { getConfigEnv } = require('../lib/common') -const clipboard = require('clipboardy') -const { wrapPromise } = require('../lib/logging') - -;(async () => { - const config = await getConfigEnv() - - await wrapPromise(clipboard.write(JSON.stringify(config)), 'Copying config to clipboard') - - console.log( - '\nTo use with CI (like Circle/Travis), create an environment variable called MINTABLE_CONFIG and paste the above result as the value.\n' - ) -})() diff --git a/src/scripts/fetch.ts b/src/scripts/fetch.ts new file mode 100644 index 00000000..a80562cb --- /dev/null +++ b/src/scripts/fetch.ts @@ -0,0 +1,118 @@ +import { getConfig } from '../common/config' +import { PlaidIntegration } from '../integrations/plaid/plaidIntegration' +import { GoogleIntegration } from '../integrations/google/googleIntegration' +import { logInfo } from '../common/logging' +import { Account } from '../types/account' +import { IntegrationId } from '../types/integrations' +import { parseISO, subMonths, startOfMonth } from 'date-fns' +import { CSVImportIntegration } from '../integrations/csv-import/csvImportIntegration' +import { CSVExportIntegration } from '../integrations/csv-export/csvExportIntegration' +import { Transaction, TransactionRuleCondition, TransactionRule } from '../types/transaction' + +export default async () => { + const config = getConfig() + + // Start date to fetch transactions, default to 2 months of history + let startDate = config.transactions.startDate + ? parseISO(config.transactions.startDate) + : startOfMonth(subMonths(new Date(), 2)) + + // End date to fetch transactions in YYYY-MM-DD format, default to current date + let endDate = config.transactions.endDate ? parseISO(config.transactions.endDate) : new Date() + + let accounts: Account[] = [] + + for (const accountId in config.accounts) { + const accountConfig = config.accounts[accountId] + + logInfo(`Fetching account ${accountConfig.id} using ${accountConfig.integration}.`) + + switch (accountConfig.integration) { + case IntegrationId.Plaid: + const plaid = new PlaidIntegration(config) + accounts = accounts.concat(await plaid.fetchAccount(accountConfig, startDate, endDate)) + break + + case IntegrationId.CSVImport: + const csv = new CSVImportIntegration(config) + accounts = accounts.concat(await csv.fetchAccount(accountConfig, startDate, endDate)) + break + + default: + break + } + } + + accounts.flat(10) + + const numTransactions = () => + accounts + .map(account => (account.hasOwnProperty('transactions') ? account.transactions.length : 0)) + .reduce((a, b) => a + b, 0) + + const totalTransactions = numTransactions() + + const transactionMatchesRule = (transaction: Transaction, rule: TransactionRule): boolean => { + return rule.conditions + .map(condition => new RegExp(condition.pattern, condition.flags).test(transaction[condition.property])) + .every(condition => condition === true) + } + + // Transaction Rules + if (config.transactions.rules) { + let countOverridden = 0 + + accounts = accounts.map(account => ({ + ...account, + transactions: account.transactions + .map(transaction => { + config.transactions.rules.forEach(rule => { + if (transaction && transactionMatchesRule(transaction, rule)) { + if (rule.type === 'filter') { + transaction = undefined + } + if (rule.type === 'override' && transaction.hasOwnProperty(rule.property)) { + transaction[rule.property] = (transaction[rule.property].toString() as String).replace( + new RegExp(rule.findPattern, rule.flags), + rule.replacePattern + ) + countOverridden += 1 + } + } + }) + + return transaction + }) + .filter(transaction => transaction !== undefined) + })) + + logInfo(`${numTransactions()} transactions out of ${totalTransactions} total transactions matched filters.`) + logInfo(`${countOverridden} out of ${totalTransactions} total transactions overridden.`) + } + + switch (config.balances.integration) { + case IntegrationId.Google: + const google = new GoogleIntegration(config) + await google.updateBalances(accounts) + break + case IntegrationId.CSVExport: + const csv = new CSVExportIntegration(config) + await csv.updateBalances(accounts) + break + default: + break + } + + switch (config.transactions.integration) { + case IntegrationId.Google: + const google = new GoogleIntegration(config) + await google.updateTransactions(accounts) + break + case IntegrationId.CSVExport: + const csv = new CSVExportIntegration(config) + await csv.updateTransactions(accounts) + break + default: + break + } +} diff --git a/src/scripts/migrate.js b/src/scripts/migrate.js deleted file mode 100644 index 8d78bd77..00000000 --- a/src/scripts/migrate.js +++ /dev/null @@ -1,48 +0,0 @@ -const _ = require('lodash') -const { writeConfig, maybeWriteDefaultConfig } = require('../lib/common') -const dotenv = require('dotenv') - -;(async () => { - dotenv.config() - - const configProperties = [ - 'PLAID_CLIENT_ID', - 'PLAID_SECRET', - 'PLAID_PUBLIC_KEY', - 'SHEETS_SHEET_ID', - 'SHEETS_CLIENT_ID', - 'SHEETS_CLIENT_SECRET', - 'SHEETS_REDIRECT_URI', - 'PLAID_TOKEN_CAPITAL_ONE', - 'PLAID_TOKEN_CHASE', - 'PLAID_TOKEN_AMEX', - 'PLAID_TOKEN_DISCOVER', - 'SHEETS_ACCESS_TOKEN', - 'SHEETS_REFRESH_TOKEN', - 'SHEETS_SCOPE', - 'SHEETS_TOKEN_TYPE', - 'SHEETS_EXPIRY_DATE', - 'CATEGORY_OVERRIDES', - 'TRANSACTION_PROVIDER', - 'SPREADSHEET_PROVIDER', - 'BALANCE_COLUMNS', - 'TRANSACTION_COLUMNS', - 'REFERENCE_COLUMNS' - ] - - let config = _.pick(process.env, configProperties) - - if (config) { - config = { - ...config, - ACCOUNT_PROVIDER: config.TRANSACTION_PROVIDER, - SHEET_PROVIDER: config.SPREADSHEET_PROVIDER - } - - await writeConfig(config) - } - - await maybeWriteDefaultConfig() - - console.log('\nSuccessfully migrated to config version 1.x.x.\n') -})() diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts new file mode 100644 index 00000000..700ab821 --- /dev/null +++ b/src/scripts/migrate.ts @@ -0,0 +1,127 @@ +import { ConfigSource, readConfig, parseConfig, getConfigSource, writeConfig } from '../common/config' +import { argv } from 'yargs' +import { logInfo, logError, logWarn } from '../common/logging' +import * as os from 'os' +import { IntegrationId, IntegrationType } from '../types/integrations' +import { defaultGoogleConfig } from '../types/integrations/google' +import { AccountConfig } from '../types/account' + +export const getOldConfig = (): ConfigSource => { + if (argv['old-config-file']) { + const path = argv['old-config-file'].replace(/^~(?=$|\/|\\)/, os.homedir()) + return { type: 'file', path: path } + } + logError('You need to specify the --old-config-file argument.') +} + +export default () => { + try { + const oldConfigSource = getOldConfig() + const oldConfigString = readConfig(oldConfigSource) + let oldConfig = parseConfig(oldConfigString) + + const deprecatedProperties = ['HOST', 'PORT', 'CATEGORY_OVERRIDES', 'DEBUG', 'CREATE_BALANCES_SHEET', 'DEBUG'] + + deprecatedProperties.forEach(prop => { + if (oldConfig.hasOwnProperty(prop)) { + logWarn(`Config property '${prop}' is deprecated and will not be migrated.`) + if (prop === 'DEBUG') { + logInfo(`You can now use the --debug argument to log request output.`) + } + } + }) + + // Update to new Account syntax + const balanceColumns: string[] = oldConfig['BALANCE_COLUMNS'].map(col => { + switch (col) { + case 'name': + return 'institution' + case 'official_name': + return 'account' + case 'balances.available': + return 'available' + case 'balances.current': + return 'current' + case 'balances.limit': + return 'limit' + default: + return col + } + }) + + // Update to new Transaction syntax + const transactionColumns: string[] = oldConfig['TRANSACTION_COLUMNS'].map(col => { + switch (col) { + case 'category.0': + case 'category.1': + return 'category' + default: + return col + } + }) + + const accounts: { [id: string]: AccountConfig } = {} + Object.keys(oldConfig).map(key => { + if (key.includes('PLAID_TOKEN')) { + const account: AccountConfig = { + id: key.replace('PLAID_TOKEN_', ''), + integration: IntegrationId.Plaid, + token: oldConfig[key] + } + accounts[account.id] = account + } + }) + + const newConfigSource = getConfigSource() + writeConfig(newConfigSource, { + integrations: { + google: { + id: IntegrationId.Google, + type: IntegrationType.Export, + + name: 'Google Sheets', + + credentials: { + clientId: oldConfig['SHEETS_CLIENT_ID'], + clientSecret: oldConfig['SHEETS_CLIENT_SECRET'], + redirectUri: defaultGoogleConfig.credentials.redirectUri, + accessToken: oldConfig['SHEETS_ACCESS_TOKEN'], + refreshToken: oldConfig['SHEETS_REFRESH_TOKEN'], + scope: defaultGoogleConfig.credentials.scope, + tokenType: oldConfig['SHEETS_TOKEN_TYPE'], + expiryDate: parseInt(oldConfig['SHEETS_EXPIRY_DATE']) + }, + documentId: oldConfig['SHEETS_SHEET_ID'], + + template: { + documentId: oldConfig['TEMPLATE_SHEET']['SHEET_ID'], + sheetTitle: oldConfig['TEMPLATE_SHEET']['SHEET_TITLE'] + } + }, + plaid: { + id: IntegrationId.Plaid, + type: IntegrationType.Import, + + name: 'Plaid', + + environment: oldConfig['PLAID_ENVIRONMENT'], + credentials: { + clientId: oldConfig['PLAID_CLIENT_ID'], + secret: oldConfig['PLAID_SECRET'] + } + } + }, + accounts: accounts, + transactions: { + integration: IntegrationId.Google, + properties: transactionColumns.concat(oldConfig['REFERENCE_COLUMNS']) + }, + balances: { + integration: IntegrationId.Google, + properties: balanceColumns + } + }) + } catch (e) { + logError('Error migrating configuration.', e) + } +} diff --git a/src/scripts/mintable.js b/src/scripts/mintable.js deleted file mode 100644 index debd8fea..00000000 --- a/src/scripts/mintable.js +++ /dev/null @@ -1,192 +0,0 @@ -;(async () => { - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // IMPORTS - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - await require('../lib/common').maybeWriteDefaultConfig() - await require('../lib/common').getConfigEnv() - const { parse, differenceInMonths, subMonths, startOfMonth, addMonths, format } = require('date-fns') - const _ = require('lodash') - const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') - - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // FETCH BALANCES - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - - if (process.env.CREATE_BALANCES_SHEET) { - let balances - - switch (process.env.ACCOUNT_PROVIDER) { - case 'plaid': - balances = _.keyBy( - _.flatten(_.map(await require('../lib/plaid').fetchBalances(), item => item.accounts)), - 'account_id' - ) - break - default: - break - } - - switch (process.env.SHEET_PROVIDER) { - case 'sheets': - let balanceSheet = _.find( - await require('../lib/google').getSheets(process.env.SHEETS_SHEET_ID), - sheet => sheet.properties.title === 'Balances' - ) - if (!balanceSheet) { - await require('../lib/google').addSheet('Balances') - balanceSheet = _.find( - await require('../lib/google').getSheets(process.env.SHEETS_SHEET_ID), - sheet => sheet.properties.title === 'Balances' - ) - } - - const cleanedBalances = _.map(_.values(balances), account => { - if (account.error) { - return _.zipObject(process.env.BALANCE_COLUMNS, Array(process.env.BALANCE_COLUMNS.length).fill("Error")) - } - else { - return _.at(account, process.env.BALANCE_COLUMNS) - } - }) - - await require('../lib/google').updateRanges({ - range: `Balances!A1:${alphabet[process.env.BALANCE_COLUMNS.length - 1]}${_.keys(balances).length + 1}`, - values: [process.env.BALANCE_COLUMNS].concat(cleanedBalances) - }) - - await require('../lib/google').formatSheets( - [balanceSheet.properties.sheetId], - process.env.BALANCE_COLUMNS.length - ) - break - default: - break - } - } - - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // FETCH TRANSACTIONS - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - - const DATE_FORMAT_FULL = 'YYYY.MM.DD' - const DATE_FORMAT_MONTHS = 'YYYY.MM' - - // Start date to fetch transactions, or by default the beginning of last month - let startDate = process.env.START_DATE ? parse(process.env.START_DATE) : startOfMonth(subMonths(new Date(), 1)) - - // Max historical fetch is 24 months back - startDate = differenceInMonths(startDate, new Date()) >= 24 ? subMonths(new Date(), 24) : startDate - - // End date to fetch transactions in YYYY-MM-DD format, default to current date - let endDate = process.env.END_DATE ? parse(process.env.END_DATE) : new Date() - - let transactions - - // https://github.com/kevinschaich/mintable/blob/master/docs/PROVIDERS.md - switch (process.env.ACCOUNT_PROVIDER) { - case 'plaid': - transactions = await require('../lib/plaid').fetchAllCleanTransactions(startDate, endDate) - break - default: - break - } - - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // MANIPULATE TRANSACTIONS - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - - transactions = _.sortBy(transactions, 'date') - - /* - * Explode out properties of transaction objects based on TRANSACTION_COLUMNS. - * - * This allows us to have a string-based TRANSACTION_COLUMNS config property - * while still supporting disparate providers. - * - * We use lodash.get (https://lodash.com/docs/4.17.11#get) to accomplish this. - * - * For example, imagine a transaction provider provides - * a category hierarchy for each transaction, which comes as a list: - * - * { - * "name": "Amazon.com", - * "date": "2019.04.16", - * "amount": -40.22, - * "category": ["Shopping", "Online Retailers"] - * } - * - * If we set TRANSACTION_COLUMNS set to ["name", "date", "amount", "category.0"], this would get returned as - * - * { - * "name": "Amazon.com", - * "date": "2019.04.16", - * "amount": -40.22, - * "category.0": "Shopping" - * } - */ - const cleanedTransactions = _.map(transactions, transaction => - _.transform(process.env.TRANSACTION_COLUMNS, (acc, column) => (acc[column] = _.get(transaction, column)), {}) - ) - - // Calculate the number of sheets based on interval - let numberSheets = differenceInMonths(endDate, startDate) - - // First sheet should be the start of that time period - let sheets = [] - let current = startOfMonth(endDate) - - // Build an array of sheet names - for (i = 0; i <= numberSheets; i++) { - sheets.push(current) - current = subMonths(current, 1) - } - - let partitionedTransactions = _.groupBy(cleanedTransactions, transaction => { - return format(startOfMonth(transaction.date), DATE_FORMAT_MONTHS) - }) - - partitionedTransactions = _.mapKeys(partitionedTransactions, (value, key) => { - return format(parse(key), DATE_FORMAT_MONTHS) - }) - - partitionedTransactions = _.mapValues(partitionedTransactions, transactions => - _.map(transactions, transaction => ({ - ...transaction, - date: format(transaction.date, DATE_FORMAT_FULL) - })) - ) - - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // UPDATE SHEETS - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - - // Column headers in spreadsheets are defined by letters A-Z, this list gets us indexes for each letter - const options = { - // First automated column Mintable populates from transaction data - firstTransactionColumn: alphabet[0], - - // Last automated column Mintable populates from transaction data - lastTransactionColumn: alphabet[process.env.TRANSACTION_COLUMNS.length - 1], - - // First untouched reference column that automatically gets created in new sheets - firstReferenceColumn: alphabet[process.env.TRANSACTION_COLUMNS.length], - - // Last untouched reference column that automatically gets created in new sheets - lastReferenceColumn: alphabet[process.env.TRANSACTION_COLUMNS.length + process.env.REFERENCE_COLUMNS.length - 1], - - // (# transaction columns + # reference columns), Mintable will only touch this range of the sheet - numAutomatedColumns: process.env.TRANSACTION_COLUMNS.length + process.env.REFERENCE_COLUMNS.length - } - - // https://github.com/kevinschaich/mintable/blob/master/docs/PROVIDERS.md - switch (process.env.SHEET_PROVIDER) { - case 'sheets': - await require('../lib/google').updateSheets(partitionedTransactions, options) - break - default: - break - } -})().catch((e) => { - console.error(e); - process.exit(1); -}) diff --git a/src/scripts/setup.js b/src/scripts/setup.js deleted file mode 100644 index 54c5cbc9..00000000 --- a/src/scripts/setup.js +++ /dev/null @@ -1,129 +0,0 @@ -const express = require('express') -const next = require('next') -const bodyParser = require('body-parser') -const opn = require('opn') -const { - getConfigEnv, - updateConfig, - deleteConfigProperty, - maybeWriteDefaultConfig, - accountProviderSetupComplete, - sheetProviderSetupComplete, - accountSetupComplete -} = require('../lib/common') -const _ = require('lodash') - -maybeWriteDefaultConfig().then(() => { - const host = process.env.HOST - const port = process.env.PORT - const dev = process.env.NODE_ENV !== 'production' - const app = next({ dev }) - const handle = app.getRequestHandler() - - app.prepare().then(() => { - const server = express() - server.use(bodyParser.urlencoded({ extended: false })) - server.use(bodyParser.json()) - - server.get('/config', (req, res) => { - return getConfigEnv() - .then(config => - res.json({ - data: { - ...config, - accountProviderSetupComplete: accountProviderSetupComplete(), - sheetProviderSetupComplete: sheetProviderSetupComplete(), - accountSetupComplete: accountSetupComplete() - } - }) - ) - .catch(error => res.json(error)) - }) - - server.post('/config', (req, res) => { - return updateConfig(req.body.updates) - .then(config => res.json({ data: config })) - .catch(error => res.json(error)) - }) - - server.delete('/config', (req, res) => { - return deleteConfigProperty(req.body.id) - .then(config => res.json({ data: config })) - .catch(error => res.json(error)) - }) - - server.get('/balances', (req, res) => { - switch (process.env.ACCOUNT_PROVIDER) { - case 'plaid': - return require('../lib/plaid') - .fetchBalances({ quiet: true }) - .then(balances => res.json({ data: balances })) - .catch(error => res.json(error)) - default: - return res.json({ data: {} }) - } - }) - - server.post('/token', (req, res) => { - switch (process.env.ACCOUNT_PROVIDER) { - case 'plaid': - return require('../lib/plaid') - .saveAccessToken(req.body.public_token, req.body.accountNickname) - .then(res.redirect(`http://${process.env.HOST}:${process.env.PORT}/settings`)) - .catch(error => res.json(error)) - default: - return res.json({ data: {} }) - } - }) - - server.post('/update', (req, res) => { - switch (process.env.ACCOUNT_PROVIDER) { - case 'plaid': - return require('../lib/plaid') - .createPublicToken(process.env[`PLAID_TOKEN_${req.body.accountNickname}`], req.body.accountNickname) - .then(token => res.json({ data: token })) - .catch(error => res.json(error)) - default: - return res.json({ data: {} }) - } - }) - - server.get('/google-sheets-url', (req, res) => { - return require('../lib/google') - .getAuthURL() - .then(url => res.json({ data: url })) - .catch(error => res.json(error)) - }) - - server.get('/google-sheets-oauth2callback', (req, res) => { - return require('../lib/google') - .getToken(req.query.code) - .then(token => res.redirect(`http://${process.env.HOST}:${process.env.PORT}/sheet-provider-setup`)) - .catch(error => res.json(error)) - }) - - server.get('/', (req, res) => { - if (!accountProviderSetupComplete() && !accountSetupComplete() && !sheetProviderSetupComplete()) { - return res.redirect(`http://${process.env.HOST}:${process.env.PORT}/welcome`) - } else if (!accountProviderSetupComplete()) { - return res.redirect(`http://${process.env.HOST}:${process.env.PORT}/account-provider-setup`) - } else if (!accountSetupComplete()) { - return res.redirect(`http://${process.env.HOST}:${process.env.PORT}/account-setup`) - } else if (!sheetProviderSetupComplete()) { - return res.redirect(`http://${process.env.HOST}:${process.env.PORT}/sheet-provider-setup`) - } else { - return res.redirect(`http://${process.env.HOST}:${process.env.PORT}/settings`) - } - }) - - server.get('*', (req, res) => { - return handle(req, res) - }) - - server.listen(port, error => { - if (error) throw error - console.log(`> Ready on http://${process.env.HOST}:${process.env.PORT}`) - opn(`http://${process.env.HOST}:${process.env.PORT}`) - }) - }) -}) diff --git a/src/static/setup.png b/src/static/setup.png deleted file mode 100644 index ec6481a5..00000000 Binary files a/src/static/setup.png and /dev/null differ diff --git a/src/styles/style.scss b/src/styles/style.scss deleted file mode 100644 index aed7fd91..00000000 --- a/src/styles/style.scss +++ /dev/null @@ -1,218 +0,0 @@ -@import url('https://fonts.googleapis.com/css?family=Roboto:200,200i,300,300i,400,400i,600,600i,700,700i,900,900i'); - -$default: #ffffff; -$blue: #137cbd; - -.blue { - color: $blue; -} - -body, -html { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', - 'Segoe UI Emoji', 'Segoe UI Symbol'; - background: #161f27; - color: $default; - margin: 0; - padding: 0; -} - -body, -html, -#__next, -.wrapper { - width: 100%; - height: 100%; -} - -.wrapper { - display: flex; - flex-direction: row; -} - -.container { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - padding: 40px; -} - -.container.container-vc { - justify-content: center; -} - -.inner-container { - max-width: 1000px; - overflow-y: scroll; - - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE 10+ */ -} - -.inner-container::-webkit-scrollbar { - /* WebKit */ - width: 0; - height: 0; -} - -.progress-sidebar { - justify-content: center; - display: flex; - flex-direction: column; - border-right: 1px solid #ffffff30; - - span { - cursor: pointer; - font-size: 20px; - margin: 20px 80px; - font-weight: 500; - } -} - -ol { - padding: 20px 40px; - - button { - margin-top: 10px; - } -} - -h1 { - font-size: 60px; - font-weight: 600; - padding-top: 60px; -} - -button { - font-size: 20px; - font-weight: 600; - padding: 10px 20px; - border-radius: 5px; - background: $blue; - color: white; - border: 0; - cursor: pointer; -} - -code { - font-family: 'Menlo', 'Monaco', 'Courier New', 'Courier'; -} - -li { - font-size: 20px; - padding-bottom: 20px; - line-height: 30px; -} - -a { - text-decoration: none; - border-bottom: 1px solid $blue; - padding-bottom: 2px; -} - -/* unvisited link */ -a:link { - color: $blue; -} - -/* visited link */ -a:visited { - color: $blue; -} - -/* mouse over link */ -a:hover { - color: $default; - border-bottom: 1px solid $default; -} - -/* selected link */ -a:active { - color: $blue; -} - -.icon { - padding: 0 10px; - vertical-align: bottom; -} - -input { - background: #ffffff10; - border: 1px solid #ffffff30; - border-radius: 5px; - font-size: 15px; - line-height: 20px; - color: white; - padding: 10px; - margin: 5px; - font-family: 'Menlo', 'Monaco', 'Courier New', 'Courier'; -} - -.config-container { - padding: 20px; - margin: 20px; - border: 1px solid #ffffff30; - border-radius: 5px; - min-width: 700px; - - .config-property-input { - display: flex; - justify-content: center; - align-content: center; - align-items: center; - - code { - width: 60%; - } - input { - width: 40%; - } - } -} - -.accounts { - display: flex; - align-items: center; - flex-direction: column; - - .accounts-list { - display: flex; - flex-direction: column; - border: 1px solid #ffffff30; - border-radius: 5px; - margin: 20px; - align-items: center; - - .account-details { - box-sizing: border-box; - display: flex; - align-items: center; - width: 100%; - justify-content: space-between; - padding: 20px; - font-size: 20px; - - .account-nickname { - width: 60%; - } - .status { - width: 30%; - } - .button { - width: 5%; - cursor: pointer; - } - } - } - - .new-account { - display: flex; - - button, - input { - flex: 1; - margin: 20px; - } - } -} diff --git a/src/types/account.ts b/src/types/account.ts new file mode 100644 index 00000000..bf170636 --- /dev/null +++ b/src/types/account.ts @@ -0,0 +1,46 @@ +import { IntegrationId } from './integrations' +import { Transaction } from './transaction' + +export interface Account { + // where this account's information came from + integration: IntegrationId + + // unique identifier for this account + accountId?: string + // masked account number (e.g xxxx xxxx xxxx 1947) + mask?: string + + // a institution can have multiple accounts (e.g. Chase) + institution?: string + // an account has a number associated to it (e.g. Sapphire Reserve Credit Card) + account: string + + // type of account (e.g. credit card, 401k, etc.) + type?: string + + current?: number + available?: number + limit?: number + currency?: string + + // transaction list + transactions?: Transaction[] +} + +export interface BaseAccountConfig { + id: string + integration: IntegrationId +} + +export interface PlaidAccountConfig extends BaseAccountConfig { + token: string +} + +export interface CSVAccountConfig extends BaseAccountConfig { + paths: string[] + transformer: { [inputColumn: string]: keyof Transaction } + dateFormat: string + negateValues?: boolean +} + +export type AccountConfig = PlaidAccountConfig | CSVAccountConfig diff --git a/src/types/balance.ts b/src/types/balance.ts new file mode 100644 index 00000000..00ee30d6 --- /dev/null +++ b/src/types/balance.ts @@ -0,0 +1,6 @@ +import { IntegrationId } from './integrations' + +export interface BalanceConfig { + integration: IntegrationId + properties?: string[] +} diff --git a/src/types/integrations.ts b/src/types/integrations.ts new file mode 100644 index 00000000..9e3a49f6 --- /dev/null +++ b/src/types/integrations.ts @@ -0,0 +1,24 @@ +import { PlaidConfig } from './integrations/plaid' +import { GoogleConfig } from './integrations/google' +import { CSVImportConfig } from './integrations/csv-import' +import { CSVExportConfig } from './integrations/csv-export' + +export enum IntegrationType { + Import = 'import', + Export = 'export' +} + +export enum IntegrationId { + Plaid = 'plaid', + Google = 'google', + CSVImport = 'csv-import', + CSVExport = 'csv-export' +} + +export interface BaseIntegrationConfig { + id: IntegrationId + name: string + type: IntegrationType +} + +export type IntegrationConfig = PlaidConfig | GoogleConfig | CSVImportConfig | CSVExportConfig diff --git a/src/types/integrations/csv-export.ts b/src/types/integrations/csv-export.ts new file mode 100644 index 00000000..eabb3617 --- /dev/null +++ b/src/types/integrations/csv-export.ts @@ -0,0 +1,21 @@ +import { BaseIntegrationConfig, IntegrationId, IntegrationType } from '../integrations' + +export interface CSVExportConfig extends BaseIntegrationConfig { + id: IntegrationId.CSVExport + type: IntegrationType + + dateFormat: string + + transactionPath?: string + balancePath?: string +} + +export const defaultCSVExportConfig: CSVExportConfig = { + name: 'CSV-Export', + id: IntegrationId.CSVExport, + type: IntegrationType.Export, + + transactionPath: '', + balancePath: '', + dateFormat: 'yyyy-MM-dd' +} diff --git a/src/types/integrations/csv-import.ts b/src/types/integrations/csv-import.ts new file mode 100644 index 00000000..eede1ace --- /dev/null +++ b/src/types/integrations/csv-import.ts @@ -0,0 +1,13 @@ +import { BaseIntegrationConfig, IntegrationId, IntegrationType } from '../integrations' +import { Transaction } from '../transaction' + +export interface CSVImportConfig extends BaseIntegrationConfig { + id: IntegrationId.CSVImport + type: IntegrationType +} + +export const defaultCSVImportConfig: CSVImportConfig = { + name: 'CSV-import', + id: IntegrationId.CSVImport, + type: IntegrationType.Import, +} diff --git a/src/types/integrations/google.ts b/src/types/integrations/google.ts new file mode 100644 index 00000000..5c8e0dff --- /dev/null +++ b/src/types/integrations/google.ts @@ -0,0 +1,44 @@ +import { BaseIntegrationConfig, IntegrationId, IntegrationType } from '../integrations' + +export interface GoogleTemplateSheetSettings { + documentId: string + sheetTitle: string +} + +export interface GoogleCredentials { + clientId: string + clientSecret: string + redirectUri: string + + accessToken?: string + refreshToken?: string + scope?: string[] + tokenType?: string + expiryDate?: number +} + +export interface GoogleConfig extends BaseIntegrationConfig { + id: IntegrationId.Google + type: IntegrationType.Export + + credentials: GoogleCredentials + documentId: string + + dateFormat?: string + + template?: GoogleTemplateSheetSettings +} + +export const defaultGoogleConfig: GoogleConfig = { + name: '', + id: IntegrationId.Google, + type: IntegrationType.Export, + + credentials: { + clientId: '', + clientSecret: '', + redirectUri: 'urn:ietf:wg:oauth:2.0:oob', + scope: ['https://www.googleapis.com/auth/spreadsheets'], + }, + documentId: '' +} diff --git a/src/types/integrations/plaid.ts b/src/types/integrations/plaid.ts new file mode 100644 index 00000000..64fe3ec8 --- /dev/null +++ b/src/types/integrations/plaid.ts @@ -0,0 +1,37 @@ +import { BaseIntegrationConfig, IntegrationId, IntegrationType } from '../integrations' + +export enum PlaidEnvironmentType { + Development = 'development', + Sandbox = 'sandbox' +} + +export interface PlaidCredentials { + clientId: string + secret: string + + // Deprecated in July 2020; keeping as optional so configs don't break + // https://github.com/plaid/plaid-node/pull/310 + publicKey?: string +} + +export interface PlaidConfig extends BaseIntegrationConfig { + id: IntegrationId.Plaid + type: IntegrationType.Import + + environment: PlaidEnvironmentType + + credentials: PlaidCredentials +} + +export const defaultPlaidConfig: PlaidConfig = { + name: '', + id: IntegrationId.Plaid, + type: IntegrationType.Import, + + environment: PlaidEnvironmentType.Sandbox, + + credentials: { + clientId: '', + secret: '' + } +} diff --git a/src/types/transaction.ts b/src/types/transaction.ts new file mode 100644 index 00000000..564de34f --- /dev/null +++ b/src/types/transaction.ts @@ -0,0 +1,80 @@ +import { IntegrationId } from './integrations' + +export interface Transaction { + // where this transaction's information came from + integration: IntegrationId + + // merchant or transaction description + name: string + // date of transaction + date: Date + // amount of transaction (purchases are positive; refunds are negative) + amount: number + // currency of transaction + currency?: string + // type of transaction (e.g. on-line or in-store) + type: string + + // a institution can have multiple accounts (e.g. Chase) + institution?: string + // an account has a number associated to it (e.g. Sapphire Reserve Credit Card) + account?: string + // unique identifier for this account + accountId?: string + // unique identifier for this transaction + transactionId?: string + + // industry or merchant category (e.g. Entertainment) + category?: string + + // street address where the transaction occurred + address?: string + // city where the transaction occurred + city?: string + // state or province where the transaction occurred + state?: string + // postal code where the transaction occurred + postal_code?: string + // country where the transaction occurred + country?: string + // latitude where the transaction occurred + latitude?: number + // longitude where the transaction occurred + longitude?: number + + // whether the transaction has posted or not + pending?: boolean +} + +export interface TransactionRuleCondition { + property: string // property to test on (e.g. "Name") + pattern: string // regex to find matches of (e.g. "*(Wegman's|Publix|Safeway)*") + flags?: string // regex flags (e.g. "i" for case insensitivity) +} + +export interface BaseTransactionRule { + conditions: TransactionRuleCondition[] // conditions which must hold to apply this rule + type: 'filter' | 'override' +} + +export interface TransactionFilterRule extends BaseTransactionRule { + type: 'filter' +} + +export interface TransactionOverrideRule extends BaseTransactionRule { + type: 'override' + property: string // transaction property to override + findPattern: string // regex to find matches of (e.g. "*(Wegman's|Publix|Safeway)*") + replacePattern: string // regex to replace any matches with (e.g. "Grocery Stores") + flags: string // regex flags (e.g. "i" for case insensitivity) +} + +export type TransactionRule = TransactionFilterRule | TransactionOverrideRule + +export interface TransactionConfig { + integration: IntegrationId + properties?: string[] + rules?: TransactionRule[] + startDate?: string + endDate?: string +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..391f6b94 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "lib": ["es2019"], + "esModuleInterop": true, + "outDir": "lib" + } +}