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
+
+
+
+
Accounts Available to Add
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+}