├── docs ├── img │ ├── cli.png │ ├── cron.png │ ├── icon.png │ ├── logo.png │ ├── bitbar.png │ ├── mintable.png │ ├── account-setup.png │ └── github-actions.png ├── templates │ ├── chase.json │ ├── venmo.json │ ├── apple-card.json │ ├── discover-card.json │ ├── american-express.json │ └── rogers-bank-credit-card.json ├── css │ └── account-setup.css └── README.md ├── .github ├── FUNDING.yml └── workflows │ ├── test.yml │ └── fetch.yml ├── .prettierrc ├── tsconfig.json ├── src ├── types │ ├── balance.ts │ ├── integrations │ │ ├── csv-import.ts │ │ ├── csv-export.ts │ │ ├── plaid.ts │ │ └── google.ts │ ├── integrations.ts │ ├── account.ts │ └── transaction.ts ├── integrations │ ├── plaid │ │ ├── accountSetup.ts │ │ ├── setup.ts │ │ ├── account-setup.html │ │ └── plaidIntegration.ts │ ├── csv-export │ │ ├── csvExportIntegration.ts │ │ └── setup.ts │ ├── csv-import │ │ ├── setup.ts │ │ └── csvImportIntegration.ts │ └── google │ │ ├── setup.ts │ │ └── googleIntegration.ts ├── scripts │ ├── cli.ts │ ├── fetch.ts │ └── migrate.ts └── common │ ├── logging.ts │ └── config.ts ├── .gitignore ├── LICENSE ├── package.json └── README.md /docs/img/cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinschaich/mintable/HEAD/docs/img/cli.png -------------------------------------------------------------------------------- /docs/img/cron.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinschaich/mintable/HEAD/docs/img/cron.png -------------------------------------------------------------------------------- /docs/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinschaich/mintable/HEAD/docs/img/icon.png -------------------------------------------------------------------------------- /docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinschaich/mintable/HEAD/docs/img/logo.png -------------------------------------------------------------------------------- /docs/img/bitbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinschaich/mintable/HEAD/docs/img/bitbar.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [kevinschaich] 4 | -------------------------------------------------------------------------------- /docs/img/mintable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinschaich/mintable/HEAD/docs/img/mintable.png -------------------------------------------------------------------------------- /docs/img/account-setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinschaich/mintable/HEAD/docs/img/account-setup.png -------------------------------------------------------------------------------- /docs/img/github-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinschaich/mintable/HEAD/docs/img/github-actions.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "semi": false, 4 | "singleQuote": true, 5 | "jsxSingleQuote": true, 6 | "printWidth": 120 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2019"], 4 | "esModuleInterop": true, 5 | "outDir": "lib" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/types/balance.ts: -------------------------------------------------------------------------------- 1 | import { IntegrationId } from './integrations' 2 | 3 | export interface BalanceConfig { 4 | integration: IntegrationId 5 | properties?: string[] 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | 4 | build/ 5 | lib/ 6 | 7 | mintable.config.json 8 | mintable.sandbox.json 9 | mintable.jsonc 10 | 11 | node_modules/ 12 | package-lock.json 13 | yarn-error.log 14 | yarn.lock 15 | .next 16 | */.next 17 | -------------------------------------------------------------------------------- /src/types/integrations/csv-import.ts: -------------------------------------------------------------------------------- 1 | import { BaseIntegrationConfig, IntegrationId, IntegrationType } from '../integrations' 2 | import { Transaction } from '../transaction' 3 | 4 | export interface CSVImportConfig extends BaseIntegrationConfig { 5 | id: IntegrationId.CSVImport 6 | type: IntegrationType 7 | } 8 | 9 | export const defaultCSVImportConfig: CSVImportConfig = { 10 | name: 'CSV-import', 11 | id: IntegrationId.CSVImport, 12 | type: IntegrationType.Import, 13 | } 14 | -------------------------------------------------------------------------------- /docs/templates/chase.json: -------------------------------------------------------------------------------- 1 | { 2 | "accounts": { 3 | "Chase": { 4 | "paths": ["/path/to/my/chase/statements/*.csv"], 5 | "transformer": { 6 | "Description": "name", 7 | "Transaction Date": "date", 8 | "Amount": "amount", 9 | "Category": "category" 10 | }, 11 | "dateFormat": "MM/dd/yyyy", 12 | "id": "Chase", 13 | "integration": "csv-import", 14 | "negateValues": true 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docs/templates/venmo.json: -------------------------------------------------------------------------------- 1 | { 2 | "accounts": { 3 | "Venmo": { 4 | "paths": ["/path/to/my/venmo/statements/*.csv"], 5 | "transformer": { 6 | "Note": "name", 7 | "Datetime": "date", 8 | "Amount (total)": "amount", 9 | "Type+From+To": "category" 10 | }, 11 | "dateFormat": "yyyy-MM-dd'T'HH:mm:ss", 12 | "id": "Venmo", 13 | "integration": "csv-import", 14 | "negateValues": false 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docs/templates/apple-card.json: -------------------------------------------------------------------------------- 1 | { 2 | "accounts": { 3 | "Apple Card": { 4 | "paths": ["/path/to/my/apple/card/statements/*.csv"], 5 | "transformer": { 6 | "Merchant": "name", 7 | "Transaction Date": "date", 8 | "Amount (USD)": "amount", 9 | "Category": "category" 10 | }, 11 | "dateFormat": "MM/dd/yyyy", 12 | "id": "Apple Card", 13 | "integration": "csv-import", 14 | "negateValues": true 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docs/templates/discover-card.json: -------------------------------------------------------------------------------- 1 | { 2 | "accounts": { 3 | "Discover Card": { 4 | "paths": ["/path/to/my/discover/card/statements/*.csv"], 5 | "transformer": { 6 | "Description": "name", 7 | "Trans. Date": "date", 8 | "Amount": "amount", 9 | "Category": "category" 10 | }, 11 | "dateFormat": "MM/dd/yyyy", 12 | "id": "Discover Card", 13 | "integration": "csv-import", 14 | "negateValues": true 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docs/templates/american-express.json: -------------------------------------------------------------------------------- 1 | { 2 | "accounts": { 3 | "American Express": { 4 | "paths": ["/path/to/my/american/express/statements/*.csv"], 5 | "transformer": { 6 | "Description": "name", 7 | "Date": "date", 8 | "Amount": "amount", 9 | "Category": "category" 10 | }, 11 | "dateFormat": "MM/dd/yyyy", 12 | "id": "American Express", 13 | "integration": "csv-import", 14 | "negateValues": true 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [12.x, 14.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - run: npm install 25 | - run: npm list --depth=1 26 | - run: npm test 27 | -------------------------------------------------------------------------------- /docs/templates/rogers-bank-credit-card.json: -------------------------------------------------------------------------------- 1 | { 2 | "accounts": { 3 | "Rogers Bank Credit Card": { 4 | "paths": ["/path/to/my/rogers/bank/credit/card/statements/*.csv"], 5 | "transformer": { 6 | "Merchant Name": "name", 7 | "Date": "date", 8 | "Amount": "amount", 9 | "Merchant Category Description": "category" 10 | }, 11 | "dateFormat": "yyyy/MM/dd", 12 | "id": "Rogers Bank Credit Card", 13 | "integration": "csv-import", 14 | "negateValues": true 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/types/integrations/csv-export.ts: -------------------------------------------------------------------------------- 1 | import { BaseIntegrationConfig, IntegrationId, IntegrationType } from '../integrations' 2 | 3 | export interface CSVExportConfig extends BaseIntegrationConfig { 4 | id: IntegrationId.CSVExport 5 | type: IntegrationType 6 | 7 | dateFormat: string 8 | 9 | transactionPath?: string 10 | balancePath?: string 11 | } 12 | 13 | export const defaultCSVExportConfig: CSVExportConfig = { 14 | name: 'CSV-Export', 15 | id: IntegrationId.CSVExport, 16 | type: IntegrationType.Export, 17 | 18 | transactionPath: '', 19 | balancePath: '', 20 | dateFormat: 'yyyy-MM-dd' 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/fetch.yml: -------------------------------------------------------------------------------- 1 | name: Fetch 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | pull_request: 8 | branches: [ master ] 9 | 10 | # schedule: 11 | # - cron: '0 * * * *' 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | node-version: [12.x, 14.x] 21 | 22 | env: 23 | MINTABLE_CONFIG: ${{ secrets.MINTABLE_CONFIG }} 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Use Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v1 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | - run: npm install 32 | - run: npm run build 33 | - run: node ./lib/scripts/cli.js fetch --ci 34 | -------------------------------------------------------------------------------- /src/types/integrations.ts: -------------------------------------------------------------------------------- 1 | import { PlaidConfig } from './integrations/plaid' 2 | import { GoogleConfig } from './integrations/google' 3 | import { CSVImportConfig } from './integrations/csv-import' 4 | import { CSVExportConfig } from './integrations/csv-export' 5 | 6 | export enum IntegrationType { 7 | Import = 'import', 8 | Export = 'export' 9 | } 10 | 11 | export enum IntegrationId { 12 | Plaid = 'plaid', 13 | Google = 'google', 14 | CSVImport = 'csv-import', 15 | CSVExport = 'csv-export' 16 | } 17 | 18 | export interface BaseIntegrationConfig { 19 | id: IntegrationId 20 | name: string 21 | type: IntegrationType 22 | } 23 | 24 | export type IntegrationConfig = PlaidConfig | GoogleConfig | CSVImportConfig | CSVExportConfig 25 | -------------------------------------------------------------------------------- /src/types/integrations/plaid.ts: -------------------------------------------------------------------------------- 1 | import { BaseIntegrationConfig, IntegrationId, IntegrationType } from '../integrations' 2 | 3 | export enum PlaidEnvironmentType { 4 | Development = 'development', 5 | Sandbox = 'sandbox' 6 | } 7 | 8 | export interface PlaidCredentials { 9 | clientId: string 10 | secret: string 11 | 12 | // Deprecated in July 2020; keeping as optional so configs don't break 13 | // https://github.com/plaid/plaid-node/pull/310 14 | publicKey?: string 15 | } 16 | 17 | export interface PlaidConfig extends BaseIntegrationConfig { 18 | id: IntegrationId.Plaid 19 | type: IntegrationType.Import 20 | 21 | environment: PlaidEnvironmentType 22 | 23 | credentials: PlaidCredentials 24 | } 25 | 26 | export const defaultPlaidConfig: PlaidConfig = { 27 | name: '', 28 | id: IntegrationId.Plaid, 29 | type: IntegrationType.Import, 30 | 31 | environment: PlaidEnvironmentType.Sandbox, 32 | 33 | credentials: { 34 | clientId: '', 35 | secret: '' 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/types/integrations/google.ts: -------------------------------------------------------------------------------- 1 | import { BaseIntegrationConfig, IntegrationId, IntegrationType } from '../integrations' 2 | 3 | export interface GoogleTemplateSheetSettings { 4 | documentId: string 5 | sheetTitle: string 6 | } 7 | 8 | export interface GoogleCredentials { 9 | clientId: string 10 | clientSecret: string 11 | redirectUri: string 12 | 13 | accessToken?: string 14 | refreshToken?: string 15 | scope?: string[] 16 | tokenType?: string 17 | expiryDate?: number 18 | } 19 | 20 | export interface GoogleConfig extends BaseIntegrationConfig { 21 | id: IntegrationId.Google 22 | type: IntegrationType.Export 23 | 24 | credentials: GoogleCredentials 25 | documentId: string 26 | 27 | dateFormat?: string 28 | 29 | template?: GoogleTemplateSheetSettings 30 | } 31 | 32 | export const defaultGoogleConfig: GoogleConfig = { 33 | name: '', 34 | id: IntegrationId.Google, 35 | type: IntegrationType.Export, 36 | 37 | credentials: { 38 | clientId: '', 39 | clientSecret: '', 40 | redirectUri: 'urn:ietf:wg:oauth:2.0:oob', 41 | scope: ['https://www.googleapis.com/auth/spreadsheets'], 42 | }, 43 | documentId: '' 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019-present, Kevin Schaich 4 | 5 | Copyright for portions of this project are held by Evan You, 2019 as part of build-your-own-mint. All other copyright are held by Kevin Schaich, 2019-present. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /src/types/account.ts: -------------------------------------------------------------------------------- 1 | import { IntegrationId } from './integrations' 2 | import { Transaction } from './transaction' 3 | 4 | export interface Account { 5 | // where this account's information came from 6 | integration: IntegrationId 7 | 8 | // unique identifier for this account 9 | accountId?: string 10 | // masked account number (e.g xxxx xxxx xxxx 1947) 11 | mask?: string 12 | 13 | // a institution can have multiple accounts (e.g. Chase) 14 | institution?: string 15 | // an account has a number associated to it (e.g. Sapphire Reserve Credit Card) 16 | account: string 17 | 18 | // type of account (e.g. credit card, 401k, etc.) 19 | type?: string 20 | 21 | current?: number 22 | available?: number 23 | limit?: number 24 | currency?: string 25 | 26 | // transaction list 27 | transactions?: Transaction[] 28 | } 29 | 30 | export interface BaseAccountConfig { 31 | id: string 32 | integration: IntegrationId 33 | } 34 | 35 | export interface PlaidAccountConfig extends BaseAccountConfig { 36 | token: string 37 | } 38 | 39 | export interface CSVAccountConfig extends BaseAccountConfig { 40 | paths: string[] 41 | transformer: { [inputColumn: string]: keyof Transaction } 42 | dateFormat: string 43 | negateValues?: boolean 44 | } 45 | 46 | export type AccountConfig = PlaidAccountConfig | CSVAccountConfig 47 | -------------------------------------------------------------------------------- /src/integrations/plaid/accountSetup.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from '../../common/config' 2 | import { logInfo, logError } from '../../common/logging' 3 | import open from 'open' 4 | import { PlaidIntegration } from './plaidIntegration' 5 | import { IntegrationId } from '../../types/integrations' 6 | import { PlaidConfig } from '../../types/integrations/plaid' 7 | 8 | export default async () => { 9 | return new Promise(async (resolve, reject) => { 10 | try { 11 | console.log('\nThis script will help you add accounts to Plaid.\n') 12 | console.log('\n\t1. A page will open in your browser allowing you to link accounts with Plaid.') 13 | console.log('\t2. Sign in with your banking provider for each account you wish to link.') 14 | console.log("\t3. Click 'Done Linking Accounts' in your browser when you are finished.\n") 15 | 16 | const config = getConfig() 17 | const plaidConfig = config.integrations[IntegrationId.Plaid] as PlaidConfig 18 | const plaid = new PlaidIntegration(config) 19 | 20 | logInfo('Account setup in progress.') 21 | open(`http://localhost:8000?environment=${plaidConfig.environment}`) 22 | await plaid.accountSetup() 23 | 24 | logInfo('Successfully set up Plaid Account(s).') 25 | return resolve() 26 | } catch (e) { 27 | logError('Unable to set up Plaid Account(s).', e) 28 | return reject() 29 | } 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /docs/css/account-setup.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | text-align: center; 6 | } 7 | 8 | h1, 9 | h2, 10 | h3, 11 | p, 12 | th, 13 | tr, 14 | button { 15 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 16 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 17 | text-rendering: optimizeLegibility; 18 | } 19 | 20 | #logo { 21 | width: 150px; 22 | } 23 | 24 | table { 25 | border-collapse: collapse; 26 | } 27 | 28 | h1 { 29 | font-weight: 500; 30 | font-size: 60px; 31 | margin-top: 40px; 32 | color: #333; 33 | } 34 | 35 | h2 { 36 | font-weight: 300; 37 | margin-top: 40px; 38 | color: #333; 39 | } 40 | 41 | h3 { 42 | font-size: 18px; 43 | font-weight: 500; 44 | } 45 | 46 | th { 47 | font-weight: 700; 48 | } 49 | 50 | td { 51 | font-weight: 400; 52 | color: #333; 53 | } 54 | 55 | #accounts { 56 | margin-top: 80px; 57 | } 58 | 59 | #accounts-table { 60 | border-radius: 5px; 61 | background: rgb(245, 245, 245); 62 | border: 1px solid rgb(235, 235, 235); 63 | } 64 | 65 | p { 66 | padding: 10px 40px; 67 | } 68 | 69 | th { 70 | padding: 20px; 71 | } 72 | 73 | td { 74 | padding: 10px 20px; 75 | border-top: 1px solid rgb(235, 235, 235); 76 | background: #fff; 77 | } 78 | 79 | button { 80 | font-size: 18px; 81 | font-weight: 500; 82 | border-radius: 5px; 83 | padding: 10px 40px; 84 | cursor: pointer; 85 | background-color: transparent; 86 | border: 1px solid #0a85ea; 87 | color: #0a85ea; 88 | transition: all 300ms; 89 | } 90 | 91 | button:hover { 92 | color: white; 93 | background-color: #0a85ea; 94 | } 95 | 96 | button.remove { 97 | border: 1px solid #ed0d3a; 98 | color: #ed0d3a; 99 | } 100 | 101 | button.remove:hover { 102 | color: white; 103 | background-color: #ed0d3a; 104 | } 105 | 106 | #link-button { 107 | margin-top: 80px; 108 | } 109 | 110 | #done-button { 111 | border: 1px solid #2abf54; 112 | color: #2abf54; 113 | margin-top: 40px; 114 | } 115 | 116 | #done-button:hover { 117 | color: white; 118 | background-color: #2abf54; 119 | } 120 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mintable", 3 | "author": "Kevin Schaich (http://kevinschaich.io)", 4 | "license": "MIT", 5 | "version": "2.0.3", 6 | "bin": "./lib/scripts/cli.js", 7 | "preferGlobal": true, 8 | "scripts": { 9 | "build": "tsc", 10 | "watch": "tsc --watch", 11 | "lint": "prettier --check ./src/**/*.js ./src/**/*.html ./src/**/*.ts ./docs/**/*.json", 12 | "lint-fix": "prettier --write ./src/**/*.js ./src/**/*.html ./src/**/*.ts ./docs/**/*.json", 13 | "test": "npm run build && npm run lint" 14 | }, 15 | "files": [ 16 | "src", 17 | "lib", 18 | "docs", 19 | "tsconfig.json" 20 | ], 21 | "dependencies": { 22 | "@types/body-parser": "^1.19.0", 23 | "@types/express": "4.17.3", 24 | "@types/express-serve-static-core": "4.17.28", 25 | "@types/glob": "^7.1.2", 26 | "@types/lodash": "4.14.149", 27 | "@types/node": "^14.0.13", 28 | "@types/prompts": "^2.0.3", 29 | "ajv": "^6.12.0", 30 | "body-parser": "^1.19.0", 31 | "chalk": "^3.0.0", 32 | "csv-parse": "^4.10.1", 33 | "csv-stringify": "^5.5.0", 34 | "date-fns": "^2.10.0", 35 | "express": "4.17.3", 36 | "glob": "^7.1.6", 37 | "googleapis": "47.0.0", 38 | "jsonc": "^2.0.0", 39 | "lodash": "4.17.15", 40 | "open": "^7.0.2", 41 | "plaid": "^7.0.0", 42 | "prompts": "^2.3.1", 43 | "typescript": "3.8.3", 44 | "typescript-json-schema": "^0.42.0", 45 | "yargs": "^15.1.0" 46 | }, 47 | "devDependencies": { 48 | "prettier": "^1.16.4" 49 | }, 50 | "description": "Automate your personal finances – for free, with no ads, and no data collection.", 51 | "repository": { 52 | "type": "git", 53 | "url": "git+https://github.com/kevinschaich/mintable.git" 54 | }, 55 | "keywords": [ 56 | "finance", 57 | "finance-management", 58 | "personal-finance", 59 | "mint", 60 | "sheets-api", 61 | "google-sheets", 62 | "google-sheets-api", 63 | "plaid", 64 | "plaid-api", 65 | "analytics", 66 | "tracker", 67 | "finance-tracker", 68 | "personal-capital", 69 | "spreadsheet", 70 | "mintable", 71 | "money", 72 | "budget", 73 | "budgeting", 74 | "budget-management", 75 | "javascript" 76 | ], 77 | "bugs": { 78 | "url": "https://github.com/kevinschaich/mintable/issues" 79 | }, 80 | "homepage": "https://github.com/kevinschaich/mintable#readme" 81 | } 82 | -------------------------------------------------------------------------------- /src/scripts/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import prompts from 'prompts' 4 | const chalk = require('chalk') 5 | import { updateConfig, readConfig, getConfigSource } from '../common/config' 6 | import plaid from '../integrations/plaid/setup' 7 | import google from '../integrations/google/setup' 8 | import csvImport from '../integrations/csv-import/setup' 9 | import csvExport from '../integrations/csv-export/setup' 10 | import accountSetup from '../integrations/plaid/accountSetup' 11 | import fetch from './fetch' 12 | import migrate from './migrate' 13 | import { logError } from '../common/logging' 14 | ;(async function() { 15 | const logo = [ 16 | '\n', 17 | ' %', 18 | ' %%', 19 | ' %%%%%', 20 | ' %%%%%%%%', 21 | ' %%%%%%%%%%', 22 | ' %%%%%%%%%%%%', 23 | ' %%%% %%%%%%%%', 24 | ' %%% %%%%%%', 25 | ' %% %%%%%%', 26 | ' % %%%', 27 | ' %%%', 28 | ' %%', 29 | ' %', 30 | '\n' 31 | ] 32 | 33 | logo.forEach(line => { 34 | console.log(chalk.green(line)) 35 | }) 36 | 37 | console.log(' M I N T A B L E\n') 38 | 39 | const commands = { 40 | migrate: migrate, 41 | fetch: fetch, 42 | 'plaid-setup': plaid, 43 | 'account-setup': accountSetup, 44 | 'google-setup': google, 45 | 'csv-import-setup': csvImport, 46 | 'csv-export-setup': csvExport 47 | } 48 | 49 | const arg = process.argv[2] 50 | 51 | if (arg == 'setup') { 52 | const configSource = getConfigSource() 53 | if (readConfig(configSource, true)) { 54 | const overwrite = await prompts([ 55 | { 56 | type: 'confirm', 57 | name: 'confirm', 58 | message: 'Config already exists. Do you to overwrite it?', 59 | initial: false 60 | } 61 | ]) 62 | if (overwrite.confirm === false) { 63 | logError('Config update cancelled by user.') 64 | } 65 | } 66 | updateConfig(config => config, true) 67 | await plaid() 68 | await google() 69 | await accountSetup() 70 | } else if (commands.hasOwnProperty(arg)) { 71 | commands[arg]() 72 | } else { 73 | console.log(`\nmintable v${require('../../package.json').version}\n`) 74 | console.log('\nusage: mintable \n') 75 | console.log('available commands:') 76 | Object.keys(commands) 77 | .concat(['setup']) 78 | .forEach(command => console.log(`\t${command}`)) 79 | } 80 | })() 81 | -------------------------------------------------------------------------------- /src/integrations/csv-export/csvExportIntegration.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../../common/config' 2 | import { IntegrationId } from '../../types/integrations' 3 | import { logInfo, logError } from '../../common/logging' 4 | import { Account } from '../../types/account' 5 | import { Transaction } from '../../types/transaction' 6 | import { CSVExportConfig } from '../../types/integrations/csv-export' 7 | import { writeFileSync } from 'fs' 8 | import stringify from 'csv-stringify/lib/sync' 9 | import { format } from 'date-fns' 10 | 11 | export class CSVExportIntegration { 12 | config: Config 13 | CSVExportConfig: CSVExportConfig 14 | 15 | constructor(config: Config) { 16 | this.config = config 17 | this.CSVExportConfig = this.config.integrations[IntegrationId.CSVExport] as CSVExportConfig 18 | } 19 | 20 | public updateTransactions = async (accounts: Account[]) => { 21 | try { 22 | const transactions: Transaction[] = accounts.map(account => account.transactions).flat(10) 23 | 24 | // Format Dates 25 | const output = transactions.map(transaction => ({ 26 | ...transaction, 27 | date: format(transaction.date, this.CSVExportConfig.dateFormat || 'yyyy.MM') 28 | })) 29 | 30 | const data = stringify(output, { 31 | header: true, 32 | columns: this.config.transactions.properties 33 | }) 34 | 35 | writeFileSync(this.CSVExportConfig.transactionPath, data) 36 | 37 | logInfo( 38 | `Successfully exported ${transactions.length} transactions for integration ${IntegrationId.CSVExport}` 39 | ) 40 | 41 | logInfo('You can view your transactions here:\n') 42 | console.log(`${this.CSVExportConfig.transactionPath}`) 43 | } catch (error) { 44 | logError(`Error exporting transactions for integration ${IntegrationId.CSVExport}`, error) 45 | } 46 | } 47 | 48 | public updateBalances = async (accounts: Account[]) => { 49 | try { 50 | const data = stringify(accounts, { 51 | header: true, 52 | columns: this.config.balances.properties 53 | }) 54 | 55 | writeFileSync(this.CSVExportConfig.balancePath, data) 56 | 57 | logInfo( 58 | `Successfully exported ${accounts.length} account balances for integration ${IntegrationId.CSVExport}` 59 | ) 60 | 61 | logInfo('You can view your account balances here:\n') 62 | console.log(`${this.CSVExportConfig.balancePath}`) 63 | } catch (error) { 64 | logError(`Error exporting account balances for integration ${IntegrationId.CSVExport}`, error) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/types/transaction.ts: -------------------------------------------------------------------------------- 1 | import { IntegrationId } from './integrations' 2 | 3 | export interface Transaction { 4 | // where this transaction's information came from 5 | integration: IntegrationId 6 | 7 | // merchant or transaction description 8 | name: string 9 | // date of transaction 10 | date: Date 11 | // amount of transaction (purchases are positive; refunds are negative) 12 | amount: number 13 | // currency of transaction 14 | currency?: string 15 | // type of transaction (e.g. on-line or in-store) 16 | type: string 17 | 18 | // a institution can have multiple accounts (e.g. Chase) 19 | institution?: string 20 | // an account has a number associated to it (e.g. Sapphire Reserve Credit Card) 21 | account?: string 22 | // unique identifier for this account 23 | accountId?: string 24 | // unique identifier for this transaction 25 | transactionId?: string 26 | 27 | // industry or merchant category (e.g. Entertainment) 28 | category?: string 29 | 30 | // street address where the transaction occurred 31 | address?: string 32 | // city where the transaction occurred 33 | city?: string 34 | // state or province where the transaction occurred 35 | state?: string 36 | // postal code where the transaction occurred 37 | postal_code?: string 38 | // country where the transaction occurred 39 | country?: string 40 | // latitude where the transaction occurred 41 | latitude?: number 42 | // longitude where the transaction occurred 43 | longitude?: number 44 | 45 | // whether the transaction has posted or not 46 | pending?: boolean 47 | } 48 | 49 | export interface TransactionRuleCondition { 50 | property: string // property to test on (e.g. "Name") 51 | pattern: string // regex to find matches of (e.g. "*(Wegman's|Publix|Safeway)*") 52 | flags?: string // regex flags (e.g. "i" for case insensitivity) 53 | } 54 | 55 | export interface BaseTransactionRule { 56 | conditions: TransactionRuleCondition[] // conditions which must hold to apply this rule 57 | type: 'filter' | 'override' 58 | } 59 | 60 | export interface TransactionFilterRule extends BaseTransactionRule { 61 | type: 'filter' 62 | } 63 | 64 | export interface TransactionOverrideRule extends BaseTransactionRule { 65 | type: 'override' 66 | property: string // transaction property to override 67 | findPattern: string // regex to find matches of (e.g. "*(Wegman's|Publix|Safeway)*") 68 | replacePattern: string // regex to replace any matches with (e.g. "Grocery Stores") 69 | flags: string // regex flags (e.g. "i" for case insensitivity) 70 | } 71 | 72 | export type TransactionRule = TransactionFilterRule | TransactionOverrideRule 73 | 74 | export interface TransactionConfig { 75 | integration: IntegrationId 76 | properties?: string[] 77 | rules?: TransactionRule[] 78 | startDate?: string 79 | endDate?: string 80 | } 81 | -------------------------------------------------------------------------------- /src/integrations/csv-export/setup.ts: -------------------------------------------------------------------------------- 1 | import { defaultCSVExportConfig, CSVExportConfig } from '../../types/integrations/csv-export' 2 | import prompts from 'prompts' 3 | import { IntegrationId } from '../../types/integrations' 4 | import { updateConfig } from '../../common/config' 5 | import { logInfo, logError } from '../../common/logging' 6 | 7 | export default async () => { 8 | return new Promise(async (resolve, reject) => { 9 | try { 10 | console.log( 11 | '\nThis script will walk you through setting up the CSV Export integration. Follow these steps:' 12 | ) 13 | console.log('\n\t1. Choose a consistent path on your computer for exported CSV file(s).') 14 | console.log('\t2. Copy the absolute path of this file(s).') 15 | console.log('\t3. Answer the following questions:\n') 16 | 17 | const responses = await prompts([ 18 | { 19 | type: 'text', 20 | name: 'name', 21 | message: 'What would you like to call this integration?', 22 | initial: 'CSV Export', 23 | validate: (s: string) => 24 | 1 < s.length && s.length <= 64 ? true : 'Must be between 2 and 64 characters in length.' 25 | }, 26 | { 27 | type: 'text', 28 | name: 'transactionPath', 29 | message: `Where would you like to save exported transactions?`, 30 | initial: '/path/to/my/transactions.csv', 31 | validate: (s: string) => 32 | s.substring(0, 1) === '/' && s.substring(s.length - 4) === '.csv' 33 | ? true 34 | : 'Must start with `/` and end with `.csv`.' 35 | }, 36 | { 37 | type: 'text', 38 | name: 'balancePath', 39 | message: `Where would you like to save exported account balances?`, 40 | initial: '/path/to/my/account-balances.csv', 41 | validate: (s: string) => 42 | s.substring(0, 1) === '/' && s.substring(s.length - 4) === '.csv' 43 | ? true 44 | : 'Must start with `/` and end with `.csv`.' 45 | } 46 | ]) 47 | 48 | updateConfig(config => { 49 | let CSVExportConfig = 50 | (config.integrations[IntegrationId.CSVExport] as CSVExportConfig) || defaultCSVExportConfig 51 | 52 | CSVExportConfig.name = responses.name 53 | CSVExportConfig.transactionPath = responses.transactionPath 54 | CSVExportConfig.balancePath = responses.balancePath 55 | 56 | config.balances.integration = IntegrationId.CSVExport 57 | config.transactions.integration = IntegrationId.CSVExport 58 | 59 | config.integrations[IntegrationId.CSVExport] = CSVExportConfig 60 | 61 | return config 62 | }) 63 | 64 | logInfo('Successfully set up CSV Export Integration.') 65 | return resolve() 66 | } catch (e) { 67 | logError('Unable to set up CSV Export Integration.', e) 68 | return reject() 69 | } 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /src/integrations/plaid/setup.ts: -------------------------------------------------------------------------------- 1 | import { PlaidEnvironmentType, PlaidConfig, defaultPlaidConfig } from '../../types/integrations/plaid' 2 | import { updateConfig } from '../../common/config' 3 | import { IntegrationId } from '../../types/integrations' 4 | import prompts from 'prompts' 5 | import { logInfo, logError } from '../../common/logging' 6 | 7 | export default async () => { 8 | return new Promise(async (resolve, reject) => { 9 | try { 10 | console.log('\nThis script will walk you through setting up the Plaid integration. Follow these steps:') 11 | console.log('\n\t1. Visit https://plaid.com') 12 | console.log("\t2. Click 'Get API Keys'") 13 | console.log('\t3. Fill out the form and wait a few days') 14 | console.log('\t4. Once approved, visit https://dashboard.plaid.com/team/keys') 15 | console.log('\t5. Answer the following questions:\n') 16 | 17 | // @types/prompts needs updated to support choice descriptions 18 | interface ChoiceWithDescription extends prompts.Choice { 19 | description: string 20 | } 21 | 22 | const credentials = await prompts([ 23 | { 24 | type: 'text', 25 | name: 'name', 26 | message: 'What would you like to call this integration?', 27 | initial: 'Plaid', 28 | validate: (s: string) => 29 | 1 < s.length && s.length <= 64 ? true : 'Must be between 2 and 64 characters in length.' 30 | }, 31 | { 32 | type: 'select', 33 | name: 'environment', 34 | message: 'Which Plaid environment would you like to use?', 35 | choices: [ 36 | { 37 | title: 'Sandbox', 38 | description: 'Test credentials for development purposes (unlimited)', 39 | value: PlaidEnvironmentType.Sandbox 40 | }, 41 | { 42 | title: 'Development', 43 | description: 'Real credentials to financial institutions (limited to 100 Items)', 44 | value: PlaidEnvironmentType.Development 45 | } 46 | ] as ChoiceWithDescription[], 47 | initial: 0 48 | }, 49 | { 50 | type: 'password', 51 | name: 'clientId', 52 | message: 'Client ID', 53 | validate: (s: string) => (s.length === 24 ? true : 'Must be 24 characters in length.') 54 | }, 55 | { 56 | type: 'password', 57 | name: 'secret', 58 | message: "Secret (pick the one corresponding to your 'Environment' choice above)", 59 | validate: (s: string) => (s.length === 30 ? true : 'Must be 30 characters in length.') 60 | } 61 | ]) 62 | 63 | updateConfig(config => { 64 | let plaidConfig = (config.integrations[IntegrationId.Plaid] as PlaidConfig) || defaultPlaidConfig 65 | 66 | plaidConfig.name = credentials.name 67 | plaidConfig.environment = credentials.environment 68 | plaidConfig.credentials.clientId = credentials.clientId 69 | plaidConfig.credentials.secret = credentials.secret 70 | 71 | config.integrations[IntegrationId.Plaid] = plaidConfig 72 | 73 | return config 74 | }) 75 | 76 | logInfo('Successfully set up Plaid Integration.') 77 | return resolve() 78 | } catch (e) { 79 | logError('Unable to set up Plaid Integration.', e) 80 | return reject() 81 | } 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /src/integrations/csv-import/setup.ts: -------------------------------------------------------------------------------- 1 | import { defaultCSVImportConfig, CSVImportConfig } from '../../types/integrations/csv-import' 2 | import prompts from 'prompts' 3 | import { IntegrationId } from '../../types/integrations' 4 | import { updateConfig } from '../../common/config' 5 | import { logInfo, logError } from '../../common/logging' 6 | import { CSVAccountConfig } from '../../types/account' 7 | 8 | export default async () => { 9 | return new Promise(async (resolve, reject) => { 10 | try { 11 | console.log( 12 | '\nThis script will walk you through setting up the CSV Import integration. Follow these steps:' 13 | ) 14 | console.log('\n\t1. Choose a consistent folder on your computer to hold CSV files you want to import.') 15 | console.log('\t2. Copy the absolute path of this folder (globs for multiple files are also supported).') 16 | console.log('\t3. Answer the following questions:\n') 17 | 18 | const responses = await prompts([ 19 | { 20 | type: 'text', 21 | name: 'name', 22 | message: 'What would you like to call this integration?', 23 | initial: 'CSV Import', 24 | validate: (s: string) => 25 | 1 < s.length && s.length <= 64 ? true : 'Must be between 2 and 64 characters in length.' 26 | }, 27 | { 28 | type: 'text', 29 | name: 'account', 30 | message: 'What would you like to call this account?', 31 | initial: 'My CSV Account', 32 | validate: (s: string) => 33 | 1 < s.length && s.length <= 64 ? true : 'Must be between 2 and 64 characters in length.' 34 | }, 35 | { 36 | type: 'text', 37 | name: 'path', 38 | message: "What is the path/globs to the CSV file(s) you'd like to import?", 39 | initial: '/path/to/my/csv/files/*.csv', 40 | validate: (s: string) => (s.substring(0, 1) === '/' ? true : 'Must start with `/`.') 41 | }, 42 | { 43 | type: 'text', 44 | name: 'dateFormat', 45 | message: 'What is the format of the date column in these files?', 46 | initial: 'yyyyMMdd', 47 | validate: (s: string) => 48 | 1 < s.length && s.length <= 64 ? true : 'Must be between 1 and 64 characters in length.' 49 | } 50 | ]) 51 | 52 | const defaultCSVAccountConfig: CSVAccountConfig = { 53 | paths: [responses.path], 54 | transformer: { 55 | name: 'name', 56 | date: 'date', 57 | amount: 'amount' 58 | }, 59 | dateFormat: responses.dateFormat, 60 | id: responses.account, 61 | integration: IntegrationId.CSVImport 62 | } 63 | 64 | updateConfig(config => { 65 | let CSVImportConfig = 66 | (config.integrations[IntegrationId.CSVImport] as CSVImportConfig) || defaultCSVImportConfig 67 | 68 | CSVImportConfig.name = responses.name 69 | 70 | config.integrations[IntegrationId.CSVImport] = CSVImportConfig 71 | config.accounts[responses.account] = defaultCSVAccountConfig 72 | 73 | return config 74 | }) 75 | 76 | console.log( 77 | `\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` 78 | ) 79 | 80 | logInfo('Successfully set up CSV Import Integration.') 81 | return resolve() 82 | } catch (e) { 83 | logError('Unable to set up CSV Import Integration.', e) 84 | return reject() 85 | } 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /src/common/logging.ts: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk') 2 | import { argv } from 'yargs' 3 | import { inspect } from 'util' 4 | import * as os from 'os' 5 | 6 | export enum LogLevel { 7 | Info = 'info', 8 | Warn = 'warn', 9 | Error = 'error' 10 | } 11 | 12 | export interface LogRequest { 13 | level: LogLevel 14 | message: string 15 | data?: any 16 | } 17 | 18 | const sanitize = (data: any) => { 19 | const blacklist = [ 20 | 'account.?id', 21 | 'account', 22 | 'client.?id', 23 | 'client.?secret', 24 | 'private.?key', 25 | 'private.?token', 26 | 'public.?key', 27 | 'public.?token', 28 | 'refresh.?token', 29 | 'secret', 30 | 'spreadsheet.?id', 31 | 'spreadsheet', 32 | 'token' 33 | ] 34 | 35 | if (typeof data === 'string') { 36 | blacklist.forEach(term => { 37 | data = data.replace(RegExp(`(${term}).?(.*)`, 'gi'), `$1=`) 38 | }) 39 | return data 40 | } else if (typeof data === 'boolean') { 41 | return data 42 | } else if (typeof data === 'number') { 43 | return data 44 | } else if (Array.isArray(data)) { 45 | return data.map(sanitize) 46 | } else if (typeof data === 'object') { 47 | let sanitized = {} 48 | for (const key in data) { 49 | sanitized[sanitize(key) as string] = sanitize(data[key]) 50 | } 51 | return sanitized 52 | } else { 53 | return '[redacted]' 54 | } 55 | } 56 | 57 | export const log = (request: LogRequest): void => { 58 | if (argv['ci']) { 59 | request.message = sanitize(request.message) 60 | request.data = sanitize(request.data) 61 | } 62 | 63 | const date = chalk.bold(new Date().toISOString()) 64 | const level = chalk.bold(`[${request.level.toUpperCase()}]`) 65 | const text = `${date} ${level} ${request.message}` 66 | 67 | switch (request.level) { 68 | case LogLevel.Error: 69 | console.error(chalk.red(text)) 70 | console.error('\n', chalk.red(inspect(request.data, true, 10)), '\n') 71 | 72 | const searchIssuesLink = encodeURI( 73 | `https://github.com/kevinschaich/mintable/issues?q=is:issue+${request.message}` 74 | ) 75 | const searchIssuesHelpText = `You can check if anybody else has encountered this issue on GitHub:\n${searchIssuesLink}\n` 76 | console.warn(chalk.yellow(searchIssuesHelpText)) 77 | 78 | const systemInfo = `arch: ${os.arch()}\nplatform: ${os.platform()}\nos: v${os.release()}\nmintable: v${ 79 | require('../../package.json').version 80 | }\nnode: ${process.version}` 81 | const reportIssueBody = 82 | '**Steps to Reproduce:**\n\n1.\n2.\n3.\n\n**Error:**\n\n```\n\n```\n\n**System Info:**\n\n```\n' + 83 | systemInfo + 84 | '\n```' 85 | const reportIssueLink = encodeURI( 86 | `https://github.com/kevinschaich/mintable/issues/new?title=Error:+${request.message}&body=${reportIssueBody}` 87 | ) 88 | const reportIssueHelpText = `If this is a new issue, please use this link to report it:\n${reportIssueLink}\n` 89 | console.warn(chalk.yellow(reportIssueHelpText)) 90 | 91 | process.exit(1) 92 | case LogLevel.Warn: 93 | console.warn(chalk.yellow(text)) 94 | break 95 | case LogLevel.Info: 96 | console.info(text) 97 | break 98 | default: 99 | break 100 | } 101 | 102 | if (argv['debug']) { 103 | try { 104 | console.log('\n', inspect(request.data, true, 10), '\n') 105 | } catch (e) { 106 | console.log('\n', JSON.stringify(request.data, null, 2), '\n') 107 | } 108 | } 109 | } 110 | 111 | export const logError = (message: string, data?: any): void => { 112 | log({ level: LogLevel.Error, message, data }) 113 | } 114 | 115 | export const logWarn = (message: string, data?: any): void => { 116 | log({ level: LogLevel.Warn, message, data }) 117 | } 118 | 119 | export const logInfo = (message: string, data?: any): void => { 120 | log({ level: LogLevel.Info, message, data }) 121 | } 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Mintable

2 |

Mintable

3 | 4 |

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

5 | 6 |
7 | 8 | Mintable helps you: 9 | 10 | - Keep track of your account balances 11 | - Aggregate transactions from all your banking institutions, including checking accounts, savings accounts, and credit cards 12 | - Analyze and budget your spending using a spreadsheet and formulas 13 | 14 | ![](./docs/img/mintable.png) 15 | 16 |
17 | 18 | [![](https://img.shields.io/github/actions/workflow/status/kevinschaich/mintable/test.yml?branch=master)](https://github.com/kevinschaich/mintable/actions?query=workflow%3ATest) 19 | [![](https://img.shields.io/npm/v/mintable)](https://www.npmjs.com/package/mintable) 20 | [![](https://img.shields.io/github/release/kevinschaich/mintable.svg)](https://github.com/kevinschaich/mintable/releases) 21 | [![](https://img.shields.io/github/license/kevinschaich/mintable.svg)](https://github.com/kevinschaich/mintable/blob/master/LICENSE) 22 | [![](https://img.shields.io/github/issues/kevinschaich/mintable.svg)](https://github.com/kevinschaich/mintable/issues) 23 | [![](https://img.shields.io/github/issues-pr/kevinschaich/mintable.svg)](https://github.com/kevinschaich/mintable/pulls) 24 | [![](https://img.shields.io/reddit/subreddit-subscribers/Mintable?style=social)](https://reddit.com/r/Mintable) 25 | 26 | --- 27 | 28 | ## Quickstart 29 | 30 | Requires `node >= v11.0.0`. 31 | 32 | 1. Sign up for [Plaid's Free Plan](https://plaid.com/pricing/). 33 | 2. Install Mintable: 34 | 35 | ```bash 36 | npm install -g mintable 37 | mintable setup 38 | ``` 39 | 40 | 3. Update your account balances/transactions: 41 | 42 | ``` 43 | mintable fetch 44 | ``` 45 | 46 | > **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). 47 | 48 | ## Documentation 49 | 50 | Check out the full documentation [in the `./docs` folder](./docs/README.md). 51 | 52 | ## FAQs 53 | 54 | **WTF is 'Mintable'?!** 55 | 56 | > **min·ta·ble**: _noun._ 57 | > 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). 58 | 59 | **Do I have to use Plaid?** 60 | 61 | * 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. 62 | 63 | **Do I have to use Google Sheets?** 64 | 65 | * 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. 66 | 67 | **Do I have to manually run this every time I want new transactions in my spreadsheet?** 68 | 69 | * 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.md#automatically-in-the-cloud--via-github-actions). 70 | 71 | **How do I use it with banks outside the US?** 72 | 73 | * Fork & edit the [country codes here](https://github.com/kevinschaich/mintable/blob/377257a6040ed9b6dd93d88435e53c48108b5806/src/integrations/plaid/plaidIntegration.ts#L126). Default support is for US banks. 74 | 75 | **How do I use it with Windows?** 76 | 77 | * Windows is not natively supported but you can try [this](https://github.com/kevinschaich/mintable/issues/125#issuecomment-1253961155). 78 | 79 | **It's not working!** 80 | 81 | - [File an issue](https://github.com/kevinschaich/mintable/issues) 82 | 83 | ## Alternatives 84 | 85 | - [**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). 86 | - [**Mint**](https://www.mint.com/): Owned by Intuit (TurboTax). Apps for iOS/Android/Web. 87 | - [**build-your-own-mint**](https://github.com/yyx990803/build-your-own-mint): Some assembly required. More flexible. 88 | -------------------------------------------------------------------------------- /src/integrations/google/setup.ts: -------------------------------------------------------------------------------- 1 | import { GoogleConfig, defaultGoogleConfig } from '../../types/integrations/google' 2 | import { updateConfig, getConfig } from '../../common/config' 3 | import prompts from 'prompts' 4 | import { IntegrationId } from '../../types/integrations' 5 | import open from 'open' 6 | import { GoogleIntegration } from './googleIntegration' 7 | import { logInfo, logError } from '../../common/logging' 8 | 9 | export default async () => { 10 | return new Promise(async (resolve, reject) => { 11 | try { 12 | console.log( 13 | '\nThis script will walk you through setting up the Google Sheets integration. Follow these steps:' 14 | ) 15 | console.log('\n\t1. Create a new Google Sheet (https://sheets.new)') 16 | console.log('\t2. Follow the guide here: https://developers.google.com/workspace/guides/create-credentials#desktop-app') 17 | console.log(`\t3. Make sure your app's Publishing Status is 'Testing', and add your Gmail account you wish to use as a Test User here: https://console.cloud.google.com/apis/credentials/consent`) 18 | console.log('\t4. Answer the following questions:\n') 19 | 20 | const credentials = await prompts([ 21 | { 22 | type: 'text', 23 | name: 'name', 24 | message: 'What would you like to call this integration?', 25 | initial: 'Google Sheets', 26 | validate: (s: string) => 27 | 0 < s.length && s.length <= 64 ? true : 'Must be between 0 and 64 characters in length.' 28 | }, 29 | { 30 | type: 'password', 31 | name: 'clientId', 32 | message: 'Client ID', 33 | validate: (s: string) => (s.length >= 8 ? true : 'Must be at least 8 characters in length.') 34 | }, 35 | { 36 | type: 'password', 37 | name: 'clientSecret', 38 | message: 'Client Secret', 39 | validate: (s: string) => (s.length >= 8 ? true : 'Must be at least 8 characters in length.') 40 | }, 41 | { 42 | type: 'text', 43 | name: 'documentId', 44 | message: 45 | 'Document ID (From the sheet you just created: https://docs.google.com/spreadsheets/d/DOCUMENT_ID/edit)', 46 | validate: (s: string) => (s.length >= 8 ? true : 'Must be at least 8 characters in length.') 47 | } 48 | ]) 49 | 50 | updateConfig(config => { 51 | let googleConfig = (config.integrations[IntegrationId.Google] as GoogleConfig) || defaultGoogleConfig 52 | 53 | googleConfig.name = credentials.name 54 | googleConfig.documentId = credentials.documentId 55 | googleConfig.credentials.clientId = credentials.clientId 56 | googleConfig.credentials.clientSecret = credentials.clientSecret 57 | 58 | config.integrations[IntegrationId.Google] = googleConfig 59 | 60 | config.transactions.integration = IntegrationId.Google 61 | config.balances.integration = IntegrationId.Google 62 | 63 | return config 64 | }) 65 | 66 | const google = new GoogleIntegration(getConfig()) 67 | open(google.getAuthURL()) 68 | 69 | console.log('\n\t5. A link will open in your browser asking you to sign in') 70 | console.log('\t6. Sign in with the account you want to use with Mintable') 71 | console.log( 72 | "\t7. If you see a page saying 'This app isn't verified', click 'Advanced' and then 'Go to app (unsafe)'" 73 | ) 74 | console.log("\t8. Click 'Allow' on both of the next two screens") 75 | console.log('\t9. Copy & paste the code from your browser below:\n') 76 | 77 | const authentication = await prompts([ 78 | { 79 | type: 'password', 80 | name: 'code', 81 | message: 'Enter the code from your browser here', 82 | validate: (s: string) => (s.length >= 8 ? true : 'Must be at least 8 characters in length.') 83 | } 84 | ]) 85 | 86 | const tokens = await google.getAccessTokens(authentication.code) 87 | await google.saveAccessTokens(tokens) 88 | 89 | logInfo('Successfully set up Google Integration.') 90 | return resolve() 91 | } catch (e) { 92 | logError('Unable to set up Plaid Integration.', e) 93 | return reject() 94 | } 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /src/integrations/plaid/account-setup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 |

Mintable

9 |

Plaid Account Setup

10 |
11 | 12 | 13 | 14 |
15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /src/scripts/fetch.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from '../common/config' 2 | import { PlaidIntegration } from '../integrations/plaid/plaidIntegration' 3 | import { GoogleIntegration } from '../integrations/google/googleIntegration' 4 | import { logInfo } from '../common/logging' 5 | import { Account } from '../types/account' 6 | import { IntegrationId } from '../types/integrations' 7 | import { parseISO, subMonths, startOfMonth } from 'date-fns' 8 | import { CSVImportIntegration } from '../integrations/csv-import/csvImportIntegration' 9 | import { CSVExportIntegration } from '../integrations/csv-export/csvExportIntegration' 10 | import { Transaction, TransactionRuleCondition, TransactionRule } from '../types/transaction' 11 | 12 | export default async () => { 13 | const config = getConfig() 14 | 15 | // Start date to fetch transactions, default to 2 months of history 16 | let startDate = config.transactions.startDate 17 | ? parseISO(config.transactions.startDate) 18 | : startOfMonth(subMonths(new Date(), 2)) 19 | 20 | // End date to fetch transactions in YYYY-MM-DD format, default to current date 21 | let endDate = config.transactions.endDate ? parseISO(config.transactions.endDate) : new Date() 22 | 23 | let accounts: Account[] = [] 24 | 25 | for (const accountId in config.accounts) { 26 | const accountConfig = config.accounts[accountId] 27 | 28 | logInfo(`Fetching account ${accountConfig.id} using ${accountConfig.integration}.`) 29 | 30 | switch (accountConfig.integration) { 31 | case IntegrationId.Plaid: 32 | const plaid = new PlaidIntegration(config) 33 | accounts = accounts.concat(await plaid.fetchAccount(accountConfig, startDate, endDate)) 34 | break 35 | 36 | case IntegrationId.CSVImport: 37 | const csv = new CSVImportIntegration(config) 38 | accounts = accounts.concat(await csv.fetchAccount(accountConfig, startDate, endDate)) 39 | break 40 | 41 | default: 42 | break 43 | } 44 | } 45 | 46 | accounts.flat(10) 47 | 48 | const numTransactions = () => 49 | accounts 50 | .map(account => (account.hasOwnProperty('transactions') ? account.transactions.length : 0)) 51 | .reduce((a, b) => a + b, 0) 52 | 53 | const totalTransactions = numTransactions() 54 | 55 | const transactionMatchesRule = (transaction: Transaction, rule: TransactionRule): boolean => { 56 | return rule.conditions 57 | .map(condition => new RegExp(condition.pattern, condition.flags).test(transaction[condition.property])) 58 | .every(condition => condition === true) 59 | } 60 | 61 | // Transaction Rules 62 | if (config.transactions.rules) { 63 | let countOverridden = 0 64 | 65 | accounts = accounts.map(account => ({ 66 | ...account, 67 | transactions: account.transactions 68 | .map(transaction => { 69 | config.transactions.rules.forEach(rule => { 70 | if (transaction && transactionMatchesRule(transaction, rule)) { 71 | if (rule.type === 'filter') { 72 | transaction = undefined 73 | } 74 | if (rule.type === 'override' && transaction.hasOwnProperty(rule.property)) { 75 | transaction[rule.property] = (transaction[rule.property].toString() as String).replace( 76 | new RegExp(rule.findPattern, rule.flags), 77 | rule.replacePattern 78 | ) 79 | countOverridden += 1 80 | } 81 | } 82 | }) 83 | 84 | return transaction 85 | }) 86 | .filter(transaction => transaction !== undefined) 87 | })) 88 | 89 | logInfo(`${numTransactions()} transactions out of ${totalTransactions} total transactions matched filters.`) 90 | logInfo(`${countOverridden} out of ${totalTransactions} total transactions overridden.`) 91 | } 92 | 93 | switch (config.balances.integration) { 94 | case IntegrationId.Google: 95 | const google = new GoogleIntegration(config) 96 | await google.updateBalances(accounts) 97 | break 98 | case IntegrationId.CSVExport: 99 | const csv = new CSVExportIntegration(config) 100 | await csv.updateBalances(accounts) 101 | break 102 | default: 103 | break 104 | } 105 | 106 | switch (config.transactions.integration) { 107 | case IntegrationId.Google: 108 | const google = new GoogleIntegration(config) 109 | await google.updateTransactions(accounts) 110 | break 111 | case IntegrationId.CSVExport: 112 | const csv = new CSVExportIntegration(config) 113 | await csv.updateTransactions(accounts) 114 | break 115 | default: 116 | break 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/scripts/migrate.ts: -------------------------------------------------------------------------------- 1 | import { ConfigSource, readConfig, parseConfig, getConfigSource, writeConfig } from '../common/config' 2 | import { argv } from 'yargs' 3 | import { logInfo, logError, logWarn } from '../common/logging' 4 | import * as os from 'os' 5 | import { IntegrationId, IntegrationType } from '../types/integrations' 6 | import { defaultGoogleConfig } from '../types/integrations/google' 7 | import { AccountConfig } from '../types/account' 8 | 9 | export const getOldConfig = (): ConfigSource => { 10 | if (argv['old-config-file']) { 11 | const path = argv['old-config-file'].replace(/^~(?=$|\/|\\)/, os.homedir()) 12 | return { type: 'file', path: path } 13 | } 14 | logError('You need to specify the --old-config-file argument.') 15 | } 16 | 17 | export default () => { 18 | try { 19 | const oldConfigSource = getOldConfig() 20 | const oldConfigString = readConfig(oldConfigSource) 21 | let oldConfig = parseConfig(oldConfigString) 22 | 23 | const deprecatedProperties = ['HOST', 'PORT', 'CATEGORY_OVERRIDES', 'DEBUG', 'CREATE_BALANCES_SHEET', 'DEBUG'] 24 | 25 | deprecatedProperties.forEach(prop => { 26 | if (oldConfig.hasOwnProperty(prop)) { 27 | logWarn(`Config property '${prop}' is deprecated and will not be migrated.`) 28 | if (prop === 'DEBUG') { 29 | logInfo(`You can now use the --debug argument to log request output.`) 30 | } 31 | } 32 | }) 33 | 34 | // Update to new Account syntax 35 | const balanceColumns: string[] = oldConfig['BALANCE_COLUMNS'].map(col => { 36 | switch (col) { 37 | case 'name': 38 | return 'institution' 39 | case 'official_name': 40 | return 'account' 41 | case 'balances.available': 42 | return 'available' 43 | case 'balances.current': 44 | return 'current' 45 | case 'balances.limit': 46 | return 'limit' 47 | default: 48 | return col 49 | } 50 | }) 51 | 52 | // Update to new Transaction syntax 53 | const transactionColumns: string[] = oldConfig['TRANSACTION_COLUMNS'].map(col => { 54 | switch (col) { 55 | case 'category.0': 56 | case 'category.1': 57 | return 'category' 58 | default: 59 | return col 60 | } 61 | }) 62 | 63 | const accounts: { [id: string]: AccountConfig } = {} 64 | Object.keys(oldConfig).map(key => { 65 | if (key.includes('PLAID_TOKEN')) { 66 | const account: AccountConfig = { 67 | id: key.replace('PLAID_TOKEN_', ''), 68 | integration: IntegrationId.Plaid, 69 | token: oldConfig[key] 70 | } 71 | accounts[account.id] = account 72 | } 73 | }) 74 | 75 | const newConfigSource = getConfigSource() 76 | writeConfig(newConfigSource, { 77 | integrations: { 78 | google: { 79 | id: IntegrationId.Google, 80 | type: IntegrationType.Export, 81 | 82 | name: 'Google Sheets', 83 | 84 | credentials: { 85 | clientId: oldConfig['SHEETS_CLIENT_ID'], 86 | clientSecret: oldConfig['SHEETS_CLIENT_SECRET'], 87 | redirectUri: defaultGoogleConfig.credentials.redirectUri, 88 | accessToken: oldConfig['SHEETS_ACCESS_TOKEN'], 89 | refreshToken: oldConfig['SHEETS_REFRESH_TOKEN'], 90 | scope: defaultGoogleConfig.credentials.scope, 91 | tokenType: oldConfig['SHEETS_TOKEN_TYPE'], 92 | expiryDate: parseInt(oldConfig['SHEETS_EXPIRY_DATE']) 93 | }, 94 | documentId: oldConfig['SHEETS_SHEET_ID'], 95 | 96 | template: { 97 | documentId: oldConfig['TEMPLATE_SHEET']['SHEET_ID'], 98 | sheetTitle: oldConfig['TEMPLATE_SHEET']['SHEET_TITLE'] 99 | } 100 | }, 101 | plaid: { 102 | id: IntegrationId.Plaid, 103 | type: IntegrationType.Import, 104 | 105 | name: 'Plaid', 106 | 107 | environment: oldConfig['PLAID_ENVIRONMENT'], 108 | credentials: { 109 | clientId: oldConfig['PLAID_CLIENT_ID'], 110 | secret: oldConfig['PLAID_SECRET'] 111 | } 112 | } 113 | }, 114 | accounts: accounts, 115 | transactions: { 116 | integration: IntegrationId.Google, 117 | properties: transactionColumns.concat(oldConfig['REFERENCE_COLUMNS']) 118 | }, 119 | balances: { 120 | integration: IntegrationId.Google, 121 | properties: balanceColumns 122 | } 123 | }) 124 | } catch (e) { 125 | logError('Error migrating configuration.', e) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/integrations/csv-import/csvImportIntegration.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../../common/config' 2 | import { IntegrationId } from '../../types/integrations' 3 | import { logInfo, logError, logWarn } from '../../common/logging' 4 | import { AccountConfig, Account, CSVAccountConfig } from '../../types/account' 5 | import { Transaction } from '../../types/transaction' 6 | import { CSVImportConfig } from '../../types/integrations/csv-import' 7 | import glob from 'glob' 8 | import { readFileSync } from 'fs' 9 | import parse from 'csv-parse/lib/sync' 10 | import * as dateFns from 'date-fns' 11 | 12 | export class CSVImportIntegration { 13 | config: Config 14 | CSVImportConfig: CSVImportConfig 15 | 16 | constructor(config: Config) { 17 | this.config = config 18 | this.CSVImportConfig = this.config.integrations[IntegrationId.CSVImport] as CSVImportConfig 19 | } 20 | 21 | public fetchAccount = async (accountConfig: AccountConfig, startDate: Date, endDate: Date): Promise => { 22 | return new Promise(async (resolve, reject) => { 23 | const CSVAccountConfig = accountConfig as CSVAccountConfig 24 | 25 | const account: Account = { 26 | account: accountConfig.id, 27 | integration: accountConfig.integration, 28 | transactions: [] 29 | } 30 | 31 | // parse file globs 32 | account.transactions = CSVAccountConfig.paths 33 | .map(path => { 34 | try { 35 | const files = glob.sync(path) 36 | 37 | if (files.length === 0) { 38 | logError(`No files resolved for path glob ${path}.`) 39 | } 40 | 41 | return files.map(match => { 42 | try { 43 | const rows = parse(readFileSync(match), { 44 | columns: true, 45 | skip_empty_lines: true 46 | }) 47 | 48 | const transactions: Transaction[] = rows.map(inputRow => { 49 | const outputRow = {} as Transaction 50 | 51 | Object.keys(CSVAccountConfig.transformer).map(inputColumn => { 52 | // Concatenate multiple columns 53 | if (inputColumn.includes('+')) { 54 | outputRow[CSVAccountConfig.transformer[inputColumn] as string] = inputColumn 55 | .split('+') 56 | .map(c => inputRow[c]) 57 | .join(' - ') 58 | } else { 59 | outputRow[CSVAccountConfig.transformer[inputColumn] as string] = 60 | inputRow[inputColumn] 61 | } 62 | }) 63 | 64 | // Remove spaces/special characters from amount field 65 | if (outputRow.hasOwnProperty('amount')) { 66 | const pattern = new RegExp(`[^0-9\.\-]*`, 'gi') 67 | outputRow['amount'] = parseFloat(outputRow['amount'].toString().replace(pattern, '')) 68 | } 69 | 70 | // Parse dates 71 | if (outputRow.hasOwnProperty('date')) { 72 | outputRow['date'] = dateFns.parse( 73 | outputRow['date'].toString(), 74 | CSVAccountConfig.dateFormat, 75 | new Date() 76 | ) 77 | } 78 | 79 | if (CSVAccountConfig.negateValues === true && outputRow.hasOwnProperty('amount')) { 80 | outputRow['amount'] = -outputRow['amount'] 81 | } 82 | 83 | if (!outputRow.hasOwnProperty('account')) { 84 | outputRow.account = CSVAccountConfig.id 85 | } 86 | 87 | if (!outputRow.hasOwnProperty('pending')) { 88 | outputRow.pending = false 89 | } 90 | 91 | return outputRow 92 | }) 93 | 94 | logInfo(`Successfully imported transactions from ${match}.`) 95 | 96 | return transactions.filter(transaction => { 97 | if (transaction.hasOwnProperty('date')) { 98 | return transaction.date >= startDate && transaction.date <= endDate 99 | } 100 | return true 101 | }) 102 | } catch (e) { 103 | logError(`Error importing transactions from ${match}.`, e) 104 | } 105 | }) 106 | } catch (e) { 107 | logError(`Error resolving path glob ${path}.`, e) 108 | } 109 | }) 110 | .flat(10) 111 | 112 | logInfo(`Successfully imported transactions for integration ${IntegrationId.CSVImport}`, account) 113 | return resolve(account) 114 | }) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/common/config.ts: -------------------------------------------------------------------------------- 1 | import { IntegrationConfig, IntegrationId } from '../types/integrations' 2 | import { AccountConfig } from '../types/account' 3 | import { TransactionConfig } from '../types/transaction' 4 | import { logInfo, logError } from './logging' 5 | import { argv } from 'yargs' 6 | import * as fs from 'fs' 7 | import * as os from 'os' 8 | import { resolve, join } from 'path' 9 | import { Definition, CompilerOptions, PartialArgs, getProgramFromFiles, generateSchema } from 'typescript-json-schema' 10 | import Ajv from 'ajv' 11 | import { BalanceConfig } from '../types/balance' 12 | import { jsonc } from 'jsonc' 13 | 14 | const DEFAULT_CONFIG_FILE = '~/mintable.jsonc' 15 | const DEFAULT_CONFIG_VAR = 'MINTABLE_CONFIG' 16 | 17 | const DEFAULT_CONFIG: Config = { 18 | accounts: {}, 19 | transactions: { 20 | integration: IntegrationId.Google, 21 | properties: ['date', 'amount', 'name', 'account', 'category'] 22 | }, 23 | balances: { 24 | integration: IntegrationId.Google, 25 | properties: ['institution', 'account', 'type', 'current', 'available', 'limit', 'currency'] 26 | }, 27 | integrations: {} 28 | } 29 | 30 | export interface FileConfig { 31 | type: 'file' 32 | path: string 33 | } 34 | 35 | export interface EnvironmentConfig { 36 | type: 'environment' 37 | variable: string 38 | } 39 | 40 | export type ConfigSource = FileConfig | EnvironmentConfig 41 | 42 | export interface Config { 43 | integrations: { [id: string]: IntegrationConfig } 44 | accounts: { [id: string]: AccountConfig } 45 | transactions: TransactionConfig 46 | balances: BalanceConfig 47 | } 48 | 49 | export const getConfigSource = (): ConfigSource => { 50 | if (argv['config-file']) { 51 | const path = argv['config-file'].replace(/^~(?=$|\/|\\)/, os.homedir()) 52 | logInfo(`Using configuration file \`${path}.\``) 53 | return { type: 'file', path: path } 54 | } 55 | 56 | if (process.env[DEFAULT_CONFIG_VAR]) { 57 | logInfo(`Using configuration variable '${DEFAULT_CONFIG_VAR}.'`) 58 | return { type: 'environment', variable: DEFAULT_CONFIG_VAR } 59 | } 60 | 61 | // Default to DEFAULT_CONFIG_FILE 62 | const path = DEFAULT_CONFIG_FILE.replace(/^~(?=$|\/|\\)/, os.homedir()) 63 | logInfo(`Using default configuration file \`${path}.\``) 64 | logInfo(`You can supply either --config-file or --config-variable to specify a different configuration.`) 65 | return { type: 'file', path: path } 66 | } 67 | 68 | export const readConfig = (source: ConfigSource, checkExists?: boolean): string => { 69 | if (source.type === 'file') { 70 | try { 71 | const config = fs.readFileSync(source.path, 'utf8') 72 | logInfo('Successfully opened configuration file.') 73 | return config 74 | } catch (e) { 75 | if (checkExists) { 76 | logInfo('Unable to open config file.') 77 | } else { 78 | logError('Unable to open configuration file.', e) 79 | logInfo("You may want to run `mintable setup` (or `mintable migrate`) if you haven't already.") 80 | } 81 | } 82 | } 83 | if (source.type === 'environment') { 84 | try { 85 | const config = process.env[source.variable] 86 | 87 | if (config === undefined) { 88 | throw `Variable \`${source.variable}\` not defined in environment.` 89 | } 90 | 91 | logInfo('Successfully retrieved configuration variable.') 92 | return config 93 | } catch (e) { 94 | if (!checkExists) { 95 | logInfo('Unable to read config variable from env.') 96 | } else { 97 | logError('Unable to read config variable from env.', e) 98 | } 99 | } 100 | } 101 | } 102 | 103 | export const parseConfig = (configString: string): Object => { 104 | try { 105 | const parsedConfig = jsonc.parse(configString) 106 | logInfo('Successfully parsed configuration.') 107 | return parsedConfig 108 | } catch (e) { 109 | logError('Unable to parse configuration.', e) 110 | } 111 | } 112 | 113 | export const getConfigSchema = (): Definition => { 114 | const basePath = resolve(join(__dirname, '../..')) 115 | const config = resolve(join(basePath, 'src/common/config.ts')) 116 | const tsconfig = require(resolve(join(basePath, 'tsconfig.json'))) 117 | const types = resolve(join(basePath, 'node_modules/@types')) 118 | 119 | // Generate JSON schema at runtime for Config interface above 120 | const compilerOptions: CompilerOptions = { 121 | ...tsconfig.compilerOptions, 122 | typeRoots: [types], 123 | baseUrl: basePath 124 | } 125 | 126 | const settings: PartialArgs = { 127 | required: true, 128 | defaultProps: true, 129 | noExtraProps: true 130 | } 131 | 132 | try { 133 | const program = getProgramFromFiles([config], compilerOptions, basePath) 134 | const configSchema = generateSchema(program, 'Config', settings) 135 | 136 | return configSchema 137 | } catch (e) { 138 | logError('Could not generate config schema.', e) 139 | } 140 | } 141 | 142 | export const validateConfig = (parsedConfig: Object): Config => { 143 | const configSchema = getConfigSchema() 144 | 145 | // Validate parsed configuration object against generated JSON schema 146 | try { 147 | const validator = new Ajv() 148 | const valid = validator.validate(configSchema, parsedConfig) 149 | 150 | if (!valid) { 151 | logError('Unable to validate configuration.', validator.errors) 152 | } 153 | } catch (e) { 154 | logError('Unable to validate configuration.', e) 155 | } 156 | 157 | const validatedConfig = parsedConfig as Config 158 | logInfo('Successfully validated configuration.') 159 | return validatedConfig 160 | } 161 | 162 | export const getConfig = (): Config => { 163 | const configSource = getConfigSource() 164 | const configString = readConfig(configSource) 165 | const parsedConfig = parseConfig(configString) 166 | const validatedConfig = validateConfig(parsedConfig) 167 | return validatedConfig 168 | } 169 | 170 | export const writeConfig = (source: ConfigSource, config: Config): void => { 171 | if (source.type === 'file') { 172 | try { 173 | fs.writeFileSync(source.path, jsonc.stringify(config, null, 2)) 174 | logInfo('Successfully wrote configuration file.') 175 | } catch (e) { 176 | logError('Unable to write configuration file.', e) 177 | } 178 | } 179 | if (source.type === 'environment') { 180 | logError( 181 | 'Node does not have permissions to modify global environment variables. Please use file-based configuration to make changes.' 182 | ) 183 | } 184 | } 185 | 186 | type ConfigTransformer = (oldConfig: Config) => Config 187 | 188 | export const updateConfig = (configTransformer: ConfigTransformer, initialize?: boolean): Config => { 189 | let newConfig: Config 190 | const configSource = getConfigSource() 191 | 192 | if (initialize) { 193 | newConfig = configTransformer(DEFAULT_CONFIG) 194 | } else { 195 | const configString = readConfig(configSource) 196 | const oldConfig = parseConfig(configString) as Config 197 | newConfig = configTransformer(oldConfig) 198 | } 199 | 200 | const validatedConfig = validateConfig(newConfig) 201 | writeConfig(configSource, validatedConfig) 202 | return validatedConfig 203 | } 204 | -------------------------------------------------------------------------------- /src/integrations/plaid/plaidIntegration.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { parseISO, format, subMonths } from 'date-fns' 3 | import plaid, { TransactionsResponse, CreateLinkTokenOptions } from 'plaid' 4 | import { Config, updateConfig } from '../../common/config' 5 | import { PlaidConfig, PlaidEnvironmentType } from '../../types/integrations/plaid' 6 | import { IntegrationId } from '../../types/integrations' 7 | import express from 'express' 8 | import bodyParser from 'body-parser' 9 | import { logInfo, logError, logWarn } from '../../common/logging' 10 | import http from 'http' 11 | import { AccountConfig, Account, PlaidAccountConfig } from '../../types/account' 12 | import { Transaction } from '../../types/transaction' 13 | 14 | const PLAID_USER_ID = 'LOCAL' 15 | 16 | export class PlaidIntegration { 17 | config: Config 18 | plaidConfig: PlaidConfig 19 | environment: string 20 | client: plaid.Client 21 | user: plaid.User 22 | 23 | constructor(config: Config) { 24 | this.config = config 25 | this.plaidConfig = this.config.integrations[IntegrationId.Plaid] as PlaidConfig 26 | 27 | this.environment = 28 | this.plaidConfig.environment === PlaidEnvironmentType.Development 29 | ? plaid.environments.development 30 | : plaid.environments.sandbox 31 | 32 | this.client = new plaid.Client({ 33 | clientID: this.plaidConfig.credentials.clientId, 34 | secret: this.plaidConfig.credentials.secret, 35 | env: this.environment, 36 | options: { 37 | version: '2019-05-29' 38 | } 39 | }) 40 | 41 | // In production this is supposed to be a unique identifier but for Mintable we only have one user (you) 42 | this.user = { 43 | client_user_id: PLAID_USER_ID 44 | } 45 | } 46 | 47 | public exchangeAccessToken = (accessToken: string): Promise => 48 | // Exchange an expired API access_token for a new Link public_token 49 | this.client.createPublicToken(accessToken).then(token => token.public_token) 50 | 51 | public savePublicToken = (tokenResponse: plaid.TokenResponse): void => { 52 | updateConfig(config => { 53 | config.accounts[tokenResponse.item_id] = { 54 | id: tokenResponse.item_id, 55 | integration: IntegrationId.Plaid, 56 | token: tokenResponse.access_token 57 | } 58 | this.config = config 59 | return config 60 | }) 61 | } 62 | 63 | public accountSetup = (): Promise => { 64 | return new Promise((resolve, reject) => { 65 | const client = this.client 66 | const app = express() 67 | .use(bodyParser.json()) 68 | .use(bodyParser.urlencoded({ extended: true })) 69 | .use(express.static(path.resolve(path.join(__dirname, '../../../docs')))) 70 | 71 | let server: http.Server 72 | 73 | app.post('/get_access_token', (req, res) => { 74 | if (req.body.public_token !== undefined) { 75 | client.exchangePublicToken(req.body.public_token, (error, tokenResponse) => { 76 | if (error != null) { 77 | reject(logError('Encountered error exchanging Plaid public token.', error)) 78 | } 79 | this.savePublicToken(tokenResponse) 80 | resolve(logInfo('Plaid access token saved.', req.body)) 81 | }) 82 | } else if (req.body.exit !== undefined) { 83 | resolve(logInfo('Plaid authentication exited.', req.body)) 84 | } else { 85 | if ((req.body.error['error-code'] = 'item-no-error')) { 86 | resolve(logInfo('Account is OK, no further action is required.', req.body)) 87 | } else { 88 | reject(logError('Encountered error during authentication.', req.body)) 89 | } 90 | } 91 | return res.json({}) 92 | }) 93 | 94 | app.post('/accounts', async (req, res) => { 95 | let accounts: { name: string; token: string }[] = [] 96 | 97 | for (const accountId in this.config.accounts) { 98 | const accountConfig: PlaidAccountConfig = this.config.accounts[accountId] as PlaidAccountConfig 99 | if (accountConfig.integration === IntegrationId.Plaid) { 100 | try { 101 | await this.client.getAccounts(accountConfig.token).then(resp => { 102 | accounts.push({ 103 | name: resp.accounts[0].name, 104 | token: accountConfig.token 105 | }) 106 | }) 107 | } catch { 108 | accounts.push({ 109 | name: 'Error fetching account name', 110 | token: accountConfig.token 111 | }) 112 | } 113 | } 114 | } 115 | return res.json(accounts) 116 | }) 117 | 118 | app.post('/create_link_token', async (req, res) => { 119 | const clientUserId = this.user.client_user_id 120 | const country_codes = process.env.COUNTRY_CODES ? process.env.COUNTRY_CODES.split(',') : ['US'] 121 | const language = process.env.LANGUAGE ? process.env.LANGUAGE : 'en' 122 | const options: CreateLinkTokenOptions = { 123 | user: { 124 | client_user_id: clientUserId 125 | }, 126 | client_name: 'Mintable', 127 | products: ['transactions'], 128 | country_codes, 129 | language 130 | } 131 | if (req.body.access_token) { 132 | options.access_token = req.body.access_token 133 | delete options.products 134 | } 135 | this.client.createLinkToken(options, (err, data) => { 136 | if (err) { 137 | logError('Error creating Plaid link token.', err) 138 | } 139 | logInfo('Successfully created Plaid link token.') 140 | res.json({ link_token: data.link_token }) 141 | }) 142 | }) 143 | 144 | app.post('/remove', async (req, res) => { 145 | try { 146 | await updateConfig(config => { 147 | Object.values(config.accounts).forEach(account => { 148 | const accountConfig: PlaidAccountConfig = account as PlaidAccountConfig 149 | 150 | if (accountConfig.hasOwnProperty('token') && accountConfig.token == req.body.token) { 151 | delete config.accounts[accountConfig.id] 152 | } 153 | }) 154 | this.config = config 155 | return config 156 | }) 157 | logInfo('Successfully removed Plaid account.', req.body.token) 158 | return res.json({}) 159 | } catch (error) { 160 | logError('Error removing Plaid account.', error) 161 | } 162 | }) 163 | 164 | app.post('/done', (req, res) => { 165 | res.json({}) 166 | return server.close() 167 | }) 168 | 169 | app.get('/', (req, res) => 170 | res.sendFile(path.resolve(path.join(__dirname, '../../../src/integrations/plaid/account-setup.html'))) 171 | ) 172 | 173 | server = require('http') 174 | .createServer(app) 175 | .listen('8000') 176 | }) 177 | } 178 | 179 | public fetchPagedTransactions = async ( 180 | accountConfig: AccountConfig, 181 | startDate: Date, 182 | endDate: Date 183 | ): Promise => { 184 | return new Promise(async (resolve, reject) => { 185 | accountConfig = accountConfig as PlaidAccountConfig 186 | try { 187 | const dateFormat = 'yyyy-MM-dd' 188 | const start = format(startDate, dateFormat) 189 | const end = format(endDate, dateFormat) 190 | 191 | let options: plaid.TransactionsRequestOptions = { count: 500, offset: 0 } 192 | let accounts = await this.client.getTransactions(accountConfig.token, start, end, options) 193 | 194 | while (accounts.transactions.length < accounts.total_transactions) { 195 | options.offset += options.count 196 | const next_page = await this.client.getTransactions(accountConfig.token, start, end, options) 197 | accounts.transactions = accounts.transactions.concat(next_page.transactions) 198 | } 199 | 200 | return resolve(accounts) 201 | } catch (e) { 202 | return reject(e) 203 | } 204 | }) 205 | } 206 | 207 | public fetchAccount = async (accountConfig: AccountConfig, startDate: Date, endDate: Date): Promise => { 208 | if (startDate < subMonths(new Date(), 5)) { 209 | logWarn('Transaction history older than 6 months may not be available for some institutions.', {}) 210 | } 211 | 212 | return this.fetchPagedTransactions(accountConfig, startDate, endDate) 213 | .then(data => { 214 | let accounts: Account[] = data.accounts.map(account => ({ 215 | integration: IntegrationId.Plaid, 216 | accountId: account.account_id, 217 | mask: account.mask, 218 | institution: account.name, 219 | account: account.official_name, 220 | type: account.subtype || account.type, 221 | current: account.balances.current, 222 | available: account.balances.available, 223 | limit: account.balances.limit, 224 | currency: account.balances.iso_currency_code || account.balances.unofficial_currency_code 225 | })) 226 | 227 | const transactions: Transaction[] = data.transactions.map(transaction => ({ 228 | integration: IntegrationId.Plaid, 229 | name: transaction.name, 230 | date: parseISO(transaction.date), 231 | amount: transaction.amount, 232 | currency: transaction.iso_currency_code || transaction.unofficial_currency_code, 233 | type: transaction.transaction_type, 234 | accountId: transaction.account_id, 235 | transactionId: transaction.transaction_id, 236 | pendingtransactionId: transaction.pending_transaction_id, 237 | category: transaction.category.join(' - '), 238 | address: transaction.location.address, 239 | city: transaction.location.city, 240 | state: transaction.location.region, 241 | postal_code: transaction.location.postal_code, 242 | country: transaction.location.country, 243 | latitude: transaction.location.lat, 244 | longitude: transaction.location.lon, 245 | pending: transaction.pending 246 | })) 247 | 248 | accounts = accounts.map(account => ({ 249 | ...account, 250 | transactions: transactions 251 | .filter(transaction => transaction.accountId === account.accountId) 252 | .map(transaction => ({ 253 | ...transaction, 254 | institution: account.institution, 255 | account: account.account 256 | })) 257 | })) 258 | 259 | logInfo( 260 | `Fetched ${data.accounts.length} sub-accounts and ${data.total_transactions} transactions.`, 261 | accounts 262 | ) 263 | return accounts 264 | }) 265 | .catch(error => { 266 | logError(`Error fetching account ${accountConfig.id}.`, error) 267 | return [] 268 | }) 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | #### Table of Contents 4 | 5 | - [Overview](#overview) 6 | - [Installation](#installation) 7 | - [Creating a Fresh Installation](#creating-a-fresh-installation) 8 | - [Migrating from `v1.x.x`](#migrating-from-v1xx) 9 | - [Importing Account Balances & Transactions](#importing-account-balances--transactions) 10 | - [Automatically – in the cloud – via Plaid](#automatically-in-the-cloud--via-plaid) 11 | - [Manually – on your local machine – via CSV bank statements](#manually--on-your-local-machine--via-csv-bank-statements) 12 | - [Exporting Account Balances & Transactions](#exporting-account-balances--transactions) 13 | - [In the cloud – via Google Sheets](#in-the-cloud-via-google-sheets) 14 | - [On your local machine – via CSV files](#on-your-local-machine--via-csv-files) 15 | - [Updating Transactions/Accounts](#updating-transactionsaccounts) 16 | - [Manually – in your local machine's terminal](#manually-in-your-local-machines-terminal) 17 | - [Automatically – in your Mac's Menu Bar – via BitBar](#automatically-in-your-macs-menu-bar--via-bitbar) 18 | - [Automatically – in your local machine's terminal – via `cron`](#automatically-in-your-local-machines-terminal--via-cron) 19 | - [Automatically – in the cloud – via GitHub Actions](#automatically-in-the-cloud--via-github-actions) 20 | - [Transaction Rules](#transaction-rules) 21 | - [Transaction `filter` Rules](#transaction-filter-rules) 22 | - [Transaction `override` Rules](#transaction-override-rules) 23 | - [Development](#development) 24 | - [Contributing](#contributing) 25 | 26 | ## Overview 27 | 28 | ![Mintable](./img/mintable.png) 29 | 30 | Mintable simplifies managing your finances, for free, without ads, and without tracking your information. Here's how it works: 31 | 32 | 1. You connect your accounts and a spreadsheet to Mintable. 33 | 1. Mintable integrates with your financial institutions to automatically populate transactions into the leftmost columns in your spreadsheet. 34 | 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. 35 | 36 | --- 37 | 38 | ## Installation 39 | 40 | ### Creating a Fresh Installation 41 | 42 | 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. 43 | 2. Install the global `mintable` command line utility: 44 | 45 | ```bash 46 | npm install -g mintable 47 | ``` 48 | 49 | 3. Set up the integration with your banks and a spreadsheet using the setup wizard: 50 | 51 | ```bash 52 | mintable setup 53 | ``` 54 | 55 | 4. Update your account balances/transactions: 56 | 57 | ``` 58 | mintable fetch 59 | ``` 60 | 61 | ![Mintable CLI](./img/cli.png) 62 | 63 | ### Migrating from `v1.x.x` 64 | 65 | > **⚠️ 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. 66 | 67 | 1. [Disable the Public Key in your Plaid Dashboard](https://plaid.com/docs/upgrade-to-link-tokens/#disable-the-public-key) (read ⚠️ above!) 68 | 69 | 2. Install the new `v2.x.x` `mintable` command line utility: 70 | 71 | ```bash 72 | npm install -g mintable 73 | ``` 74 | 75 | 3. Migrate your config to the new format: 76 | 77 | ```bash 78 | mintable migrate --old-config-file /path/to/your/old/mintable.config.json 79 | ``` 80 | 81 | 4. Update your account balances/transactions: 82 | 83 | ```bash 84 | mintable fetch 85 | ``` 86 | 87 | > **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. 88 | 89 | --- 90 | 91 | ## Importing Account Balances & Transactions 92 | 93 | ### Automatically – in the cloud – via [Plaid](https://plaid.com) 94 | 95 | You can run: 96 | 97 | ```bash 98 | mintable plaid-setup 99 | ``` 100 | 101 | 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. 102 | 103 | After you have the base Plaid integration working, you can run: 104 | 105 | ```bash 106 | mintable account-setup 107 | ``` 108 | 109 | to enter the account setup wizard to add, update, or remove accounts. 110 | 111 | ![Account Setup](./img/account-setup.png) 112 | 113 | This will launch a local web server (necessary to authenticate with Plaid's servers) for you to connect your banks. 114 | 115 | 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. 116 | 117 | > **Note:** Plaid is the default import integration and these steps are not necessary if you've already run `mintable setup`. 118 | 119 | ### Manually – on your local machine – via CSV bank statements 120 | 121 | You can run: 122 | 123 | ```bash 124 | mintable csv-import-setup 125 | ``` 126 | 127 | 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. 128 | 129 | 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. 130 | 131 | We have a number of templates available for popular financial institutions to get you started: 132 | 133 | - [Apple Card](./templates/apple-card.json) 134 | - [Discover Card](./templates/discover-card.json) 135 | - [Venmo](./templates/venmo.json) 136 | - [Chase](./templates/chase.json) 137 | - [American Express](./templates/american-express.json) 138 | - [Rogers Bank Credit Card](./templates/rogers-bank-credit-card.json) 139 | 140 | These templates can be added into the `accounts` section of your `mintable.jsonc` configuration file. 141 | 142 | > **Note:** CSV Imports do not support account balances. 143 | 144 | --- 145 | 146 | ## Exporting Account Balances & Transactions 147 | 148 | ### In the cloud – via [Google Sheets](https://www.google.com/sheets/about/) 149 | 150 | You can run: 151 | 152 | ```bash 153 | mintable google-setup 154 | ``` 155 | 156 | 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. 157 | 158 | > **Note:** Google Sheets is the default export integration and this step is not necessary if you've already run `mintable setup`. 159 | 160 | ### On your local machine – via CSV files 161 | 162 | You can run: 163 | 164 | ```bash 165 | mintable csv-export-setup 166 | ``` 167 | 168 | 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. 169 | 170 | --- 171 | 172 | ## Updating Transactions/Accounts 173 | 174 | ### Manually – in your local machine's terminal 175 | 176 | After you have connected a banking institution, you can run: 177 | 178 | ```bash 179 | mintable fetch 180 | ``` 181 | 182 | to automate updates to your spreadsheet. 183 | 184 | ### Automatically – in your Mac's Menu Bar – via [BitBar](https://github.com/matryer/bitbar#get-started) 185 | 186 | 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). 187 | 188 | ![BitBar](./img/bitbar.png) 189 | 190 | 1. [Install BitBar](https://github.com/matryer/bitbar/releases) on your Mac. 191 | 2. Set your plugin folder. 192 | 3. Create a new file in `mintable.1h.zsh` in your plugin folder. 193 | 4. Copy & paste [this](https://github.com/matryer/bitbar-plugins/blob/39e8f252ed69d0dd46bbe095299e52279e86d737/Finance/mintable.1h.zsh) into the file you just created and save. 194 | 5. Open **BitBar** > **Preferences** > **Refresh All** to update your spreadsheet. 195 | 196 | > **Note:** The plugin above is pending approval and this install process should be much easier moving forward. 197 | 198 | ### Automatically – in your local machine's terminal – via `cron` 199 | 200 | You can run Mintable automatically within your terminal using `cron`: 201 | 202 | ![`cron`](./img/cron.png) 203 | 204 | ```bash 205 | echo "0 * * * * export PATH="/usr/local/bin:$PATH" && mintable fetch" > ~/mintable.cron 206 | crontab ~/mintable.cron 207 | ``` 208 | 209 | 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. 210 | 211 | The default refresh interval is 1 hour – you can use [Crontab Guru](https://crontab.guru/) to define your own interval. 212 | 213 | You can remove this schedule by running: 214 | 215 | ```bash 216 | crontab -r 217 | ``` 218 | 219 | > **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. 220 | 221 | ### Automatically – in the cloud – via GitHub Actions 222 | 223 | You can use GitHub Actions to run Mintable automatically in the cloud: 224 | 225 | ![GitHub Actions](./img/github-actions.png) 226 | 227 | 1. Fork [this repo](https://github.com/kevinschaich/mintable). 228 | 2. Go to your repo's **Actions** > Click **I understand my workflows, go ahead and enable them** 229 | 3. Go to your repo's **Settings** > **Secrets** and add a **New Secret**. 230 | 4. Name the secret `MINTABLE_CONFIG`, and copy and paste the full contents of your `~/mintable.jsonc` file into the body of the secret. 231 | 5. In your repo's `./.github/workflows/fetch.yml`, uncomment the following block and commit the changes: 232 | 233 | ``` 234 | # schedule: 235 | # - cron: '0 * * * *' 236 | ``` 237 | 238 | 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. 239 | 240 | > **Note:** The minimum interval supported by GitHub Actions is every 5 minutes. 241 | 242 | --- 243 | 244 | ## Transaction Rules 245 | 246 | ### Transaction `filter` Rules 247 | 248 | 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). 249 | 250 | For example, if you wanted to exclude any cross-account transfers, you might add the following to your `transactions.rules` config: 251 | 252 | ```json 253 | "rules": [ 254 | { 255 | "conditions": [ 256 | { 257 | "property": "name", 258 | "pattern": "(transfer|xfer|trnsfr)", 259 | "flags": "ig" 260 | } 261 | ], 262 | "type": "filter" 263 | } 264 | ] 265 | ``` 266 | 267 | ### Transaction `override` Rules 268 | 269 | 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). 270 | 271 | 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). 272 | 273 | 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: 274 | 275 | ```json 276 | "rules": [ 277 | { 278 | "conditions": [ 279 | { 280 | "property": "name", 281 | "pattern": "(dunkin|starbucks|peets|philz)", 282 | "flags": "ig" 283 | } 284 | ], 285 | "type": "override", 286 | "property": "category", 287 | "findPattern": "Fast Food", 288 | "replacePattern": "Coffee Shops", 289 | "flags": "i" 290 | } 291 | ] 292 | ``` 293 | 294 | When you run `mintable fetch` the next time, the category would be `Restaurants – Coffee Shops`. 295 | 296 | ## Development 297 | 298 | To get started: 299 | 300 | ```bash 301 | git clone https://github.com/kevinschaich/mintable 302 | cd mintable 303 | 304 | npm install 305 | npm run build 306 | npm link 307 | ``` 308 | 309 | The global `mintable` command will now point to your local version (`/lib/scripts/cli.js`). To start compilation in watch mode: 310 | 311 | ```bash 312 | npm run watch 313 | ``` 314 | 315 | To publish a new version, increment `version` in `package.json` and run: 316 | 317 | ```bash 318 | git add package.json 319 | git commit -m 'vX.X.X' 320 | git push 321 | 322 | rm -rf lib/ 323 | npm run build 324 | npm publish 325 | ``` 326 | 327 | To revert to the production release of `mintable`, run: 328 | 329 | ```bash 330 | npm unlink 331 | npm install -g mintable 332 | ``` 333 | 334 | ## Contributing 335 | 336 | 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). 337 | -------------------------------------------------------------------------------- /src/integrations/google/googleIntegration.ts: -------------------------------------------------------------------------------- 1 | import { google, sheets_v4 } from 'googleapis' 2 | import { Config, updateConfig } from '../../common/config' 3 | import { IntegrationId } from '../../types/integrations' 4 | import { GoogleConfig } from '../../types/integrations/google' 5 | import { OAuth2Client, Credentials } from 'google-auth-library' 6 | import { logInfo, logError } from '../../common/logging' 7 | import { Account } from '../../types/account' 8 | import { sortBy, groupBy } from 'lodash' 9 | import { startOfMonth, format, formatISO, parseISO } from 'date-fns' 10 | 11 | export interface Range { 12 | sheet: string 13 | start: string 14 | end: string 15 | } 16 | 17 | export interface DataRange { 18 | range: Range 19 | data: any[][] 20 | } 21 | 22 | export class GoogleIntegration { 23 | config: Config 24 | googleConfig: GoogleConfig 25 | client: OAuth2Client 26 | sheets: sheets_v4.Resource$Spreadsheets 27 | 28 | constructor(config: Config) { 29 | this.config = config 30 | this.googleConfig = config.integrations[IntegrationId.Google] as GoogleConfig 31 | 32 | this.client = new google.auth.OAuth2( 33 | this.googleConfig.credentials.clientId, 34 | this.googleConfig.credentials.clientSecret, 35 | this.googleConfig.credentials.redirectUri 36 | ) 37 | 38 | this.client.setCredentials({ 39 | access_token: this.googleConfig.credentials.accessToken, 40 | refresh_token: this.googleConfig.credentials.refreshToken, 41 | token_type: this.googleConfig.credentials.tokenType, 42 | expiry_date: this.googleConfig.credentials.expiryDate 43 | }) 44 | 45 | this.sheets = google.sheets({ 46 | version: 'v4', 47 | auth: this.client 48 | }).spreadsheets 49 | } 50 | 51 | public getAuthURL = (): string => 52 | this.client.generateAuthUrl({ 53 | scope: this.googleConfig.credentials.scope 54 | }) 55 | 56 | public getAccessTokens = (authCode: string): Promise => 57 | this.client.getToken(authCode).then(response => response.tokens) 58 | 59 | public saveAccessTokens = (tokens: Credentials): void => { 60 | updateConfig(config => { 61 | let googleConfig = config.integrations[IntegrationId.Google] as GoogleConfig 62 | 63 | googleConfig.credentials.accessToken = tokens.access_token 64 | googleConfig.credentials.refreshToken = tokens.refresh_token 65 | googleConfig.credentials.tokenType = tokens.token_type 66 | googleConfig.credentials.expiryDate = tokens.expiry_date 67 | 68 | config.integrations[IntegrationId.Google] = googleConfig 69 | 70 | return config 71 | }) 72 | } 73 | 74 | public getSheets = (documentId?: string): Promise => { 75 | return this.sheets 76 | .get({ spreadsheetId: documentId || this.googleConfig.documentId }) 77 | .then(res => { 78 | logInfo(`Fetched ${res.data.sheets.length} sheets.`, res.data.sheets) 79 | return res.data.sheets 80 | }) 81 | .catch(error => { 82 | logError(`Error fetching sheets for spreadsheet ${this.googleConfig.documentId}.`, error) 83 | return [] 84 | }) 85 | } 86 | 87 | public copySheet = async (title: string, sourceDocumentId?: string): Promise => { 88 | const sheets = await this.getSheets(sourceDocumentId || this.googleConfig.documentId) 89 | let sourceSheetId 90 | 91 | try { 92 | sourceSheetId = sheets.find(sheet => sheet.properties.title === title).properties.sheetId 93 | } catch (error) { 94 | logError(`Error finding template sheet ${title} in document ${sourceDocumentId}.`, { error, sheets }) 95 | } 96 | 97 | return this.sheets.sheets 98 | .copyTo({ 99 | spreadsheetId: sourceDocumentId || this.googleConfig.documentId, 100 | sheetId: sourceSheetId, 101 | requestBody: { destinationSpreadsheetId: this.googleConfig.documentId } 102 | }) 103 | .then(res => { 104 | logInfo(`Copied sheet ${title}.`, res.data) 105 | return res.data 106 | }) 107 | .catch(error => { 108 | logError(`Error copying sheet ${title}.`, error) 109 | return {} 110 | }) 111 | } 112 | 113 | public addSheet = (title: string): Promise => { 114 | return this.sheets 115 | .batchUpdate({ 116 | spreadsheetId: this.googleConfig.documentId, 117 | requestBody: { requests: [{ addSheet: { properties: { title } } }] } 118 | }) 119 | .then(res => { 120 | logInfo(`Added sheet ${title}.`, res.data) 121 | return res.data.replies[0].addSheet.properties 122 | }) 123 | .catch(error => { 124 | logError(`Error adding sheet ${title}.`, error) 125 | return {} 126 | }) 127 | } 128 | 129 | public renameSheet = async (oldTitle: string, newTitle: string): Promise => { 130 | const sheets = await this.getSheets() 131 | const sheetId = sheets.find(sheet => sheet.properties.title === oldTitle).properties.sheetId 132 | 133 | return this.sheets 134 | .batchUpdate({ 135 | spreadsheetId: this.googleConfig.documentId, 136 | requestBody: { 137 | requests: [ 138 | { 139 | updateSheetProperties: { 140 | properties: { sheetId: sheetId, title: newTitle }, 141 | fields: 'title' 142 | } 143 | } 144 | ] 145 | } 146 | }) 147 | .then(res => { 148 | logInfo(`Renamed sheet ${oldTitle} to ${newTitle}.`, res.data) 149 | return res.data.replies 150 | }) 151 | .catch(error => { 152 | logError(`Error renaming sheet ${oldTitle} to ${newTitle}.`, error) 153 | return [] 154 | }) 155 | } 156 | 157 | public translateRange = (range: Range): string => 158 | `'${range.sheet}'!${range.start.toUpperCase()}:${range.end.toUpperCase()}` 159 | 160 | public translateRanges = (ranges: Range[]): string[] => ranges.map(this.translateRange) 161 | 162 | public clearRanges = (ranges: Range[]): Promise => { 163 | const translatedRanges = this.translateRanges(ranges) 164 | return this.sheets.values 165 | .batchClear({ 166 | spreadsheetId: this.googleConfig.documentId, 167 | requestBody: { ranges: translatedRanges } 168 | }) 169 | .then(res => { 170 | logInfo(`Cleared ${ranges.length} range(s): ${translatedRanges}.`, res.data) 171 | return res.data 172 | }) 173 | .catch(error => { 174 | logError(`Error clearing ${ranges.length} range(s): ${translatedRanges}.`, error) 175 | return {} 176 | }) 177 | } 178 | 179 | public updateRanges = (dataRanges: DataRange[]): Promise => { 180 | const data = dataRanges.map(dataRange => ({ 181 | range: this.translateRange(dataRange.range), 182 | values: dataRange.data 183 | })) 184 | return this.sheets.values 185 | .batchUpdate({ 186 | spreadsheetId: this.googleConfig.documentId, 187 | requestBody: { 188 | valueInputOption: `USER_ENTERED`, 189 | data: data 190 | } 191 | }) 192 | .then(res => { 193 | logInfo(`Updated ${data.length} range(s): ${data.map(r => r.range)}.`, res.data) 194 | return res.data 195 | }) 196 | .catch(error => { 197 | logError(`Error updating ${data.length} range(s): ${data.map(r => r.range)}.`, error) 198 | return {} 199 | }) 200 | } 201 | 202 | public sortSheets = async (): Promise => { 203 | const sheets = await this.getSheets() 204 | const ordered = sortBy(sheets, sheet => sheet.properties.title).reverse() 205 | 206 | return this.sheets 207 | .batchUpdate({ 208 | spreadsheetId: this.googleConfig.documentId, 209 | requestBody: { 210 | requests: ordered.map((sheet, i) => ({ 211 | updateSheetProperties: { 212 | properties: { sheetId: sheet.properties.sheetId, index: i }, 213 | fields: 'index' 214 | } 215 | })) 216 | } 217 | }) 218 | .then(res => { 219 | logInfo(`Updated indices for ${sheets.length} sheets.`, res.data) 220 | return res.data 221 | }) 222 | .catch(error => { 223 | logError(`Error updating indices for ${sheets.length} sheets.`, error) 224 | return {} 225 | }) 226 | } 227 | 228 | public formatSheets = async (): Promise => { 229 | const sheets = await this.getSheets() 230 | 231 | return this.sheets 232 | .batchUpdate({ 233 | spreadsheetId: this.googleConfig.documentId, 234 | requestBody: { 235 | requests: sheets 236 | .map(sheet => [ 237 | { 238 | repeatCell: { 239 | range: { sheetId: sheet.properties.sheetId, startRowIndex: 0, endRowIndex: 1 }, 240 | cell: { 241 | userEnteredFormat: { 242 | backgroundColor: { red: 0.2, green: 0.2, blue: 0.2 }, 243 | horizontalAlignment: 'CENTER', 244 | textFormat: { 245 | foregroundColor: { red: 1.0, green: 1.0, blue: 1.0 }, 246 | bold: true 247 | } 248 | } 249 | }, 250 | fields: 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)' 251 | } 252 | }, 253 | { 254 | updateSheetProperties: { 255 | properties: { 256 | sheetId: sheet.properties.sheetId, 257 | gridProperties: { frozenRowCount: 1 } 258 | }, 259 | fields: 'gridProperties.frozenRowCount' 260 | } 261 | }, 262 | { 263 | autoResizeDimensions: { 264 | dimensions: { 265 | sheetId: sheet.properties.sheetId, 266 | dimension: 'COLUMNS', 267 | startIndex: 0, 268 | endIndex: sheet.properties.gridProperties.columnCount 269 | } 270 | } 271 | } 272 | ]) 273 | .flat(10) 274 | } 275 | }) 276 | .then(res => { 277 | logInfo(`Updated formatting for ${sheets.length} sheets.`, res.data) 278 | return res.data 279 | }) 280 | .catch(error => { 281 | logError(`Error updating formatting for ${sheets.length} sheets.`, error) 282 | return {} 283 | }) 284 | } 285 | 286 | public getRowWithDefaults = (row: { [key: string]: any }, columns: string[], defaultValue: any = null): any[] => { 287 | return columns.map(key => { 288 | if (row && row.hasOwnProperty(key)) { 289 | if (key === 'date') { 290 | return format(row[key], this.googleConfig.dateFormat || 'yyyy.MM.dd') 291 | } 292 | return row[key] 293 | } 294 | return defaultValue 295 | }) 296 | } 297 | 298 | public updateSheet = async ( 299 | sheetTitle: string, 300 | rows: { [key: string]: any }[], 301 | columns?: string[], 302 | useTemplate?: boolean 303 | ): Promise => { 304 | const sheets = await this.getSheets() 305 | const existing = sheets.find(sheet => sheet.properties.title === sheetTitle) 306 | 307 | if (existing === undefined) { 308 | if (this.googleConfig.template && useTemplate === true) { 309 | const copied = await this.copySheet( 310 | this.googleConfig.template.sheetTitle, 311 | this.googleConfig.template.documentId 312 | ) 313 | await this.renameSheet(copied.title, sheetTitle) 314 | } else { 315 | await this.addSheet(sheetTitle) 316 | } 317 | } 318 | 319 | columns = columns || Object.keys(rows[0]) 320 | 321 | const columnHeaders = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') 322 | 323 | const range = { 324 | sheet: sheetTitle, 325 | start: `A1`, 326 | end: `${columnHeaders[columns.length > 0 ? columns.length - 1 : 1]}${rows.length + 1}` 327 | } 328 | const data = [columns].concat(rows.map(row => this.getRowWithDefaults(row, columns))) 329 | 330 | await this.clearRanges([range]) 331 | return this.updateRanges([{ range, data }]) 332 | } 333 | 334 | public updateTransactions = async (accounts: Account[]) => { 335 | // Sort transactions by date 336 | const transactions = sortBy(accounts.map(account => account.transactions).flat(10), 'date') 337 | 338 | // Split transactions by month 339 | const groupedTransactions = groupBy(transactions, transaction => formatISO(startOfMonth(transaction.date))) 340 | 341 | // Write transactions by month, copying template sheet if necessary 342 | for (const month in groupedTransactions) { 343 | await this.updateSheet( 344 | format(parseISO(month), this.googleConfig.dateFormat || 'yyyy.MM'), 345 | groupedTransactions[month], 346 | this.config.transactions.properties, 347 | true 348 | ) 349 | } 350 | 351 | // Sort Sheets 352 | await this.sortSheets() 353 | 354 | // Format, etc. 355 | await this.formatSheets() 356 | 357 | logInfo('You can view your sheet here:\n') 358 | console.log(`https://docs.google.com/spreadsheets/d/${this.googleConfig.documentId}`) 359 | } 360 | 361 | public updateBalances = async (accounts: Account[]) => { 362 | // Update Account Balances Sheets 363 | await this.updateSheet('Balances', accounts, this.config.balances.properties) 364 | 365 | // Sort Sheets 366 | await this.sortSheets() 367 | 368 | // Format, etc. 369 | await this.formatSheets() 370 | 371 | logInfo('You can view your sheet here:\n') 372 | console.log(`https://docs.google.com/spreadsheets/d/${this.googleConfig.documentId}`) 373 | } 374 | } 375 | --------------------------------------------------------------------------------