diff --git a/docs/README.md b/docs/README.md index 495c40bf..50d7526e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ - [Migrating from `v1.x.x`](#migrating-from-v1xx) - [Importing Account Balances & Transactions](#importing-account-balances--transactions) - [Automatically – in the cloud – via Plaid](#automatically-in-the-cloud--via-plaid) + - [Automatically – via Teller](#automatically-via-teller) - [Manually – on your local machine – via CSV bank statements](#manually--on-your-local-machine--via-csv-bank-statements) - [Exporting Account Balances & Transactions](#exporting-account-balances--transactions) - [In the cloud – via Google Sheets](#in-the-cloud-via-google-sheets) @@ -116,6 +117,32 @@ To add a new account, click the blue **Link A New Account** button. To re-authen > **Note:** Plaid is the default import integration and these steps are not necessary if you've already run `mintable setup`. +### Automatically via [Teller](https://teller.io) + +You can run: + +```bash +mintable teller-setup +``` + +to enter the Teller setup wizard. This will ask for things like the certificate, private key, and application ID provided when you sign up for Teller. + +After you have the base Teller integration working, you can run: + +```bash +mintable teller-account-setup +``` + +to enter the account setup wizard to add or remove accounts. + +This will launch a local web server (necessary to authenticate with Teller's servers) for you to connect your banks. + +To add a new account, click the blue **Link A New Account** button. + +> **Note:** Access to an account may expire. In that case, you should run `mintable teller-account-setup` to re-add the accounts with expired access. + +After set up is complete, you will import updated account balances/transactions from your banking institutions every time `mintable fetch` is run. + ### Manually – on your local machine – via CSV bank statements You can run: diff --git a/docs/css/account-setup.css b/docs/css/account-setup.css index e1b86aa6..c65920c1 100644 --- a/docs/css/account-setup.css +++ b/docs/css/account-setup.css @@ -103,6 +103,15 @@ button.remove:hover { background-color: #ed0d3a; } +button.loading, +button.loading:hover, +button.remove.loading:hover { + background: #fff; + border-color: #aaa; + color: #aaa; + cursor: wait; +} + #link-button { margin-top: 80px; } diff --git a/src/integrations/teller/account-setup.html b/src/integrations/teller/account-setup.html new file mode 100644 index 00000000..9787a10f --- /dev/null +++ b/src/integrations/teller/account-setup.html @@ -0,0 +1,128 @@ + + + + + +
+ +

Mintable

+

Teller Account Setup

+
+ + + + + +
+ + +
+ + + + + + + diff --git a/src/integrations/teller/accountSetup.ts b/src/integrations/teller/accountSetup.ts new file mode 100644 index 00000000..be961acf --- /dev/null +++ b/src/integrations/teller/accountSetup.ts @@ -0,0 +1,32 @@ +import { getConfig } from '../../common/config' +import { logInfo, logError } from '../../common/logging' +import open from 'open' +import { TellerIntegration } from './tellerIntegration' +import { IntegrationId } from '../../types/integrations' +import { TellerConfig } from '../../types/integrations/teller' + +export default () => { + return new Promise((resolve, reject) => { + try { + console.log('\nThis script will help you add accounts using Teller.\n') + console.log('\n\t1. A page will open in your browser allowing you to link accounts with Teller.') + console.log('\t2. Sign in with your banking provider for each account you wish to link.') + console.log('\t3. Click \'Done Linking Accounts\' in your browser when you are finished.\n') + + const config = getConfig() + const tellerConfig = config.integrations[IntegrationId.Teller] as TellerConfig + const teller = new TellerIntegration(config) + + logInfo('Account setup in progress.') + open(`http://localhost:8000?tellerAppId=${tellerConfig.appId}`) + teller.accountSetup() + .then(() => { + logInfo('Successfully set up Teller Account(s).') + return resolve(true) + }) + } catch (e) { + logError('Unable to set up Teller Account(s).', e) + return reject() + } + }) +} diff --git a/src/integrations/teller/setup.ts b/src/integrations/teller/setup.ts new file mode 100644 index 00000000..059f9a64 --- /dev/null +++ b/src/integrations/teller/setup.ts @@ -0,0 +1,66 @@ +import { existsSync, realpathSync } from 'fs' +import prompts from 'prompts' + +import { TellerConfig, defaultTellerConfig } from '../../types/integrations/teller' +import { updateConfig } from '../../common/config' +import { IntegrationId } from '../../types/integrations' +import { logInfo, logError } from '../../common/logging' + +export default async () => { + try { + console.log('\nThis script will walk you through setting up the Teller integration. Follow these steps:') + console.log('\n\t1. Visit https://teller.io') + console.log('\t2. Click \'Get Started\'') + console.log('\t3. Fill out the form') + console.log('\t4. Find the application ID, and download the certificate and private key') + console.log('\t5. Answer the following questions:\n') + + const credentials = await prompts([ + { + type: 'text', + name: 'name', + message: 'What would you like to call this integration?', + initial: 'Teller', + validate: (s: string) => + 1 < s.length && s.length <= 64 ? true : 'Must be between 2 and 64 characters in length.' + }, + { + type: 'text', + name: 'pathCertificate', + message: 'Path to Certificate', + validate: (s: string) => s.length && existsSync(s) ? true : 'Must enter path to certificate file.' + }, + { + type: 'text', + name: 'pathPrivateKey', + message: 'Path to Private Key', + validate: (s: string) => s.length && existsSync(s) ? true : 'Must enter path to private key file.' + }, + { + type: 'text', + name: 'appId', + message: 'Application ID', + validate: (s: string) => s.length && s.startsWith('app_') ? true : 'Must enter Application ID from Teller.' + } + ]) + + updateConfig(config => { + const tellerConfig = (config.integrations[IntegrationId.Teller] as TellerConfig) || defaultTellerConfig + + tellerConfig.name = credentials.name + tellerConfig.pathCertificate = realpathSync(credentials.pathCertificate) + tellerConfig.pathPrivateKey = realpathSync(credentials.pathPrivateKey) + tellerConfig.appId = credentials.appId + + config.integrations[IntegrationId.Teller] = tellerConfig + + return config + }) + + logInfo('Successfully set up Teller Integration.') + return true + } catch (e) { + logError('Unable to set up Teller Integration.', e) + return false + } +} diff --git a/src/integrations/teller/tellerIntegration.ts b/src/integrations/teller/tellerIntegration.ts new file mode 100644 index 00000000..148b70ed --- /dev/null +++ b/src/integrations/teller/tellerIntegration.ts @@ -0,0 +1,242 @@ +import path from 'path' +import { compareAsc, parseISO, subMonths } from 'date-fns' +import { Config, updateConfig } from '../../common/config' +import { TellerConfig, TellerTransaction } from '../../types/integrations/teller' +import { IntegrationId } from '../../types/integrations' +import express from 'express' +import bodyParser from 'body-parser' +import { logInfo, logError, logWarn } from '../../common/logging' +import http from 'http' +import https from 'https' +import { AccountConfig, Account, TellerAccountConfig } from '../../types/account' +import { Transaction } from '../../types/transaction' +import { readFileSync } from 'fs' + +export class TellerIntegration { + config: Config + tellerConfig: TellerConfig + + constructor(config: Config) { + this.config = config + this.tellerConfig = this.config.integrations[IntegrationId.Teller] as TellerConfig + } + + public tellerApi = (token: string, path: string, method = 'GET', body?: any): Promise => { + return new Promise((resolve, reject) => { + const req = https.request(`https://api.teller.io/${path}`, { + method, + auth: `${token}:`, + cert: readFileSync(this.tellerConfig.pathCertificate), + key: readFileSync(this.tellerConfig.pathPrivateKey), + }, (res) => { + const resData = [] + res.on('data', (chunk) => resData.push(chunk)) + res.on('end', () => { + const resString = Buffer.concat(resData).toString() + resolve(JSON.parse(resString)) + }) + }) + req.on('error', (e) => { + logError(`tellerApi encountered https request error: ${e.message}`, e) + reject(e) + }) + if (body) { + req.write(body) + } + req.end() + }) + } + + public registerAccount = (accessToken: string, accountId: string): void => { + updateConfig(config => { + config.accounts[accountId] = { + id: accountId, + integration: IntegrationId.Teller, + token: accessToken + } + this.config = config + return config + }) + } + + public accountSetup = (): Promise => { + return new Promise((resolve) => { + const app = express() + .use(bodyParser.json()) + .use(bodyParser.urlencoded({ extended: true })) + .use(express.static(path.resolve(path.join(__dirname, '../../../docs')))) + + let server: http.Server + + app.post('/get_enrollment_accounts', async (req, res) => { + if (!req.body.enrollment || !req.body.enrollment.accessToken) { + logError('Received invalid request body for /get_enrollment_accounts', req.body) + res.status(401) + return res.json({}) + } + + const accounts = await this.tellerApi(req.body.enrollment.accessToken, 'accounts') + return res.json(accounts) + }) + + app.post('/register_account', (req, res) => { + if (!req.body.accessToken || !req.body.accountId) { + logError('Received invalid request body for /register_account', req.body) + res.status(401) + return res.json({}) + } + this.registerAccount(req.body.accessToken, req.body.accountId) + resolve(logInfo('Teller access token saved for account.', req.body)) + return res.json({}) + }) + + app.post('/accounts', async (req, res) => { + const accounts: { id: string; name: string; token: string }[] = [] + + for (const accountId in this.config.accounts) { + const accountConfig: TellerAccountConfig = this.config.accounts[accountId] as TellerAccountConfig + if (accountConfig.integration === IntegrationId.Teller) { + const accountInfo = await this.tellerApi(accountConfig.token, `accounts/${accountConfig.id}`) + accounts.push({ + id: accountConfig.id, + name: accountInfo ? `${accountInfo.institution?.name} ${accountInfo.name} ${accountInfo.last_four}` : '-', + token: accountConfig.token + }) + } + } + return res.json(accounts) + }) + + app.post('/remove', (req, res) => { + try { + updateConfig(config => { + if (config.accounts[req.body.accountId]) { + delete config.accounts[req.body.accountId] + } + this.config = config + return config + }) + logInfo('Successfully removed Teller account.', req.body.accountId) + return res.json({}) + } catch (error) { + logError('Error removing Teller account.', error) + } + }) + + app.post('/done', (req, res) => { + res.json({}) + server.close() + return resolve() + }) + + app.get('/', (req, res) => + res.sendFile(path.resolve(path.join(__dirname, '../../../src/integrations/teller/account-setup.html'))) + ) + + server = http + .createServer(app) + .listen('8000') + }) + } + + public fetchPagedTransactions = ( + accountConfig: AccountConfig, + startDate: Date, + endDate: Date + ): Promise => { + return new Promise((resolve, reject) => { + accountConfig = accountConfig as TellerAccountConfig + try { + return this.tellerApi(accountConfig.token, `accounts/${accountConfig.id}/transactions`) + .then((ttxs: TellerTransaction[]) => { + if (!ttxs.reduce) { + logError('Received unexpected data from Teller transactions API', ttxs) + throw new Error('Received unexpected data from Teller transactions API') + } + const filteredTtxs: TellerTransaction[] = ttxs.reduce((results, ttx) => { + const ttxDate = parseISO(ttx.date) + if (compareAsc(ttxDate, startDate) !== -1 && compareAsc(ttxDate, endDate) !== 1) { + results.push(ttx) + } + return results + }, []) + logInfo(`Received ${ttxs.length} transactions. Will import ${filteredTtxs.length} transactions within date range.`) + return resolve(filteredTtxs) + }) + } catch (e) { + logError(`fetchPagedTransactions encountered error: ${e.message}`, e) + return reject(e) + } + }) + } + + public fetchAccount = async (accountConfig: AccountConfig, startDate: Date, endDate: Date): Promise => { + accountConfig = accountConfig as TellerAccountConfig + if (startDate < subMonths(new Date(), 5)) { + logWarn('Transaction history older than 6 months may not be available for some institutions.', {}) + } + + const account: Account = { + integration: IntegrationId.Teller, + accountId: accountConfig.id, + account: accountConfig.id + } + + const accountInfo = await this.tellerApi(accountConfig.token, `accounts/${accountConfig.id}`) + + if (accountInfo.error) { + logWarn('Could not fetch data for account from Teller. Authentication may be expired. Run: `mintable teller-account-setup`', {}) + throw new Error('Could not fetch data for account from Teller.') + } + + if (accountInfo) { + account.institution = accountInfo.institution?.name + account.account = accountInfo.name + account.mask = accountInfo.last_four + account.type = accountInfo.subtype || accountInfo.type + account.currency = accountInfo.currency + } + const isCredit = accountInfo && accountInfo.type === 'credit' + + const accountBalance = await this.tellerApi(accountConfig.token, `accounts/${accountConfig.id}/balances`) + + if (accountBalance) { + account.current = accountBalance.ledger + account.available = accountBalance.available + } + + const data = await this.fetchPagedTransactions(accountConfig, startDate, endDate) + + if (!data || !data.map) { + logWarn(`fetchAccount received unexpected response for transactions from ${account.institution} ${account.account}`, data) + return [] + } + + // With Teller, the amount value can be positive for both a purchase + // made with a credit card and a deposit made into a checking account. + // However in terms of a budget, a purchase should be a positive value + // and a deposit should have a negative value, so we normalize it here. + const transactions: Transaction[] = data.map(tt => ({ + integration: IntegrationId.Teller, + name: tt.description, + date: parseISO(tt.date), + amount: !isCredit ? -1 * Number(tt.amount) : Number(tt.amount), + currency: account.currency, + type: tt.type, + institution: account.institution, + account: `${account.institution} ${account.account} ${account.mask}`, + accountId: tt.account_id, + transactionId: tt.id, + category: tt.details?.category, + pending: tt.status === 'pending' + })) + + account.transactions = transactions + + logInfo( + `Fetched account with ${transactions.length} transactions.`, + account + ) + return [account] + } +} diff --git a/src/scripts/cli.ts b/src/scripts/cli.ts index 0e71a330..bb59336d 100755 --- a/src/scripts/cli.ts +++ b/src/scripts/cli.ts @@ -7,7 +7,9 @@ import plaid from '../integrations/plaid/setup' import google from '../integrations/google/setup' import csvImport from '../integrations/csv-import/setup' import csvExport from '../integrations/csv-export/setup' +import teller from '../integrations/teller/setup' import accountSetup from '../integrations/plaid/accountSetup' +import tellerAccountSetup from '../integrations/teller/accountSetup' import fetch from './fetch' import migrate from './migrate' import { logError } from '../common/logging' @@ -43,7 +45,9 @@ import { logError } from '../common/logging' 'account-setup': accountSetup, 'google-setup': google, 'csv-import-setup': csvImport, - 'csv-export-setup': csvExport + 'csv-export-setup': csvExport, + 'teller-setup': teller, + 'teller-account-setup': tellerAccountSetup } const arg = process.argv[2] diff --git a/src/scripts/fetch.ts b/src/scripts/fetch.ts index a80562cb..9a162ae0 100644 --- a/src/scripts/fetch.ts +++ b/src/scripts/fetch.ts @@ -1,5 +1,6 @@ import { getConfig } from '../common/config' import { PlaidIntegration } from '../integrations/plaid/plaidIntegration' +import { TellerIntegration } from '../integrations/teller/tellerIntegration' import { GoogleIntegration } from '../integrations/google/googleIntegration' import { logInfo } from '../common/logging' import { Account } from '../types/account' @@ -38,6 +39,11 @@ export default async () => { accounts = accounts.concat(await csv.fetchAccount(accountConfig, startDate, endDate)) break + case IntegrationId.Teller: + const teller = new TellerIntegration(config) + accounts = accounts.concat(await teller.fetchAccount(accountConfig, startDate, endDate)) + break + default: break } diff --git a/src/types/account.ts b/src/types/account.ts index bf170636..15488e71 100644 --- a/src/types/account.ts +++ b/src/types/account.ts @@ -43,4 +43,8 @@ export interface CSVAccountConfig extends BaseAccountConfig { negateValues?: boolean } -export type AccountConfig = PlaidAccountConfig | CSVAccountConfig +export interface TellerAccountConfig extends BaseAccountConfig { + token: string +} + +export type AccountConfig = PlaidAccountConfig | CSVAccountConfig | TellerAccountConfig diff --git a/src/types/integrations.ts b/src/types/integrations.ts index 9e3a49f6..7f384d8e 100644 --- a/src/types/integrations.ts +++ b/src/types/integrations.ts @@ -2,6 +2,7 @@ import { PlaidConfig } from './integrations/plaid' import { GoogleConfig } from './integrations/google' import { CSVImportConfig } from './integrations/csv-import' import { CSVExportConfig } from './integrations/csv-export' +import { TellerConfig } from './integrations/teller' export enum IntegrationType { Import = 'import', @@ -12,7 +13,8 @@ export enum IntegrationId { Plaid = 'plaid', Google = 'google', CSVImport = 'csv-import', - CSVExport = 'csv-export' + CSVExport = 'csv-export', + Teller = 'teller' } export interface BaseIntegrationConfig { @@ -21,4 +23,4 @@ export interface BaseIntegrationConfig { type: IntegrationType } -export type IntegrationConfig = PlaidConfig | GoogleConfig | CSVImportConfig | CSVExportConfig +export type IntegrationConfig = PlaidConfig | GoogleConfig | CSVImportConfig | CSVExportConfig | TellerConfig diff --git a/src/types/integrations/teller.ts b/src/types/integrations/teller.ts new file mode 100644 index 00000000..2e086e71 --- /dev/null +++ b/src/types/integrations/teller.ts @@ -0,0 +1,44 @@ +import { BaseIntegrationConfig, IntegrationId, IntegrationType } from '../integrations' + +export interface TellerConfig extends BaseIntegrationConfig { + id: IntegrationId.Teller + type: IntegrationType.Import + + pathCertificate: string, + pathPrivateKey: string, + appId: string +} + +export const defaultTellerConfig: TellerConfig = { + name: '', + id: IntegrationId.Teller, + type: IntegrationType.Import, + + pathCertificate: '', + pathPrivateKey: '', + appId: '' +} + +export interface TellerTransaction { + account_id: string, + amount: string, + date: string, + description: string, + details: TellerTransactionDetails, + status: string, + id: string, + links: TellerTransactionLinks, + running_balance?: string, + type: string +} + +export interface TellerTransactionDetails { + category?: string, + counterparty?: string, + processing_status: string +} + +export interface TellerTransactionLinks { + account: string, + self: string +}