├── .gitignore ├── README.md ├── config.sample.coffee ├── index.coffee ├── lib ├── ai.coffee ├── auth.coffee ├── parse.coffee └── utils.coffee ├── package.json └── screenshot.png /.gitignore: -------------------------------------------------------------------------------- 1 | config.coffee 2 | client_secret.json 3 | hackerwallet-token.json 4 | node_modules/* 5 | data/* 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![image](./screenshot.png) 2 | 3 | *(numbers were made up)* 4 | Hacker Wallet 5 | ============= 6 | 7 | A tool for Node.js programmers who do not trust Mint.com and similar sites. 8 | 9 | It allows you to keep track of your personal finances using just free Google Sheets and online banking, without trusting any third party sites with your banking passwords. 10 | 11 | Your passwords are stored on your local hard drive and only the transaction amounts (dates, memos, etc) travel to Google Sheets. 12 | It runs everywhere where Node.js runs, but I tested it only on OS/X and Node 6.5.0. 13 | 14 | The code quality is a bit lacking, I threw it all together on weekend. But it works for me and users are happy. 15 | 16 | How it works 17 | ------------- 18 | It pulls the data from online banking in the industry standard format (OFX) using great [Banking.js](https://github.com/euforic/banking.js). See their site for configuration information. The configuration is stored in the `config.coffee` file, the sample is provided, copy it and edit. 19 | 20 | The second part of the "solution" is the Google Sheets spreadsheet. You can copy [the one I use](https://docs.google.com/spreadsheets/d/1UVPZEttwS8SdRpJAU9u_2dR12NRYKcWWJoQ6HHaVCNQ/edit?usp=sharing) and hack it as necessary. 21 | 22 | The program creates a new sheet within this spreadsheet (the `09-2016` was created automatically) for every month of the year and then adds transactions for this month to this sheet, removing duplicates. Dedup is based on transaction reference numbers or transaction IDs. 23 | 24 | The first "main" sheet of the whole spreadsheet is used as an overall view of your budget broken down into categories. Categories are selected from monthly sheets based on the information in the column `G`. The exact names of the categories do not matter, just make sure that entries in the first column of the "main" sheet match the entries you put into the `G` column. You can put these values in the `G` column manually and/or use "artificial intelligence": 25 | 26 | The module `lib/ai.coffee` (the name is a joke, of course) contains a `classify` function, which tries to determine the category of the transaction automatically based on transaction data. Hack away. 27 | 28 | Some banks do not provide online access to OFX data (or charge an exorbitant fee or only allow such access for specific types of accounts) so you can either consider changing your bank or find a way to download OFX data as a file. This utility allows loading OFX data from files. 29 | 30 | A notable exception is the Synchrony Bank (former GE Money) which provides store cards for many offline and online retailers, including, unfortunately, Amazon. Amazon Store Card is a Synchrony Bank card and if you have such card you have no OFX data access whatsoever. This is Amazon, the company which basically became the synonym for word "online". 31 | 32 | How to load transactions from Amazon Store Card 33 | -------------- 34 | 35 | Log into your Amazon Store Card account on the [Bank website](Synchronycredit.com) 36 | and navigate to "Current activity". Once you see the list of your current transactions 37 | save the web page as an HTML file. In Chrome this is *File->Save Page As*, 38 | select *Format: Webpage, HTML Only*. You should have one HTML (or HTM) file. Specify this file to 39 | the `-f` option (don't forget `-b`) to the program and it will import your transactions. 40 | 41 | 42 | Installation 43 | -------------- 44 | 1. Make sure you have node and coffeescript installed 45 | 2. Checkout the project and install dependencies: `npm install` 46 | 1. Copy the `config.sample.coffee` to `config.coffee` and edit banking information according to the [Banking.js](https://github.com/euforic/banking.js) page. 47 | 1. Create a Google Sheets spreadsheet or copy mine and copy the spreadsheet ID to `config.coffee`. Spreadsheet ID is a long sequence of characters between `/d/` and `/edit` in the URL. 48 | 2. Now you need to register your script as an application with Google and authorize it to alter information in Google Sheets: 49 | * Perform actions listed in [Step 1](https://developers.google.com/sheets/quickstart/nodejs) 50 | * Run the application for the first time `npm run app` and it will ask you to visit some URL at Google and copy the code provided there. 51 | * After you successfully complete these steps they will not be necessary going forward, the credentials and Oauth token are cached locally. 52 | 53 | License 54 | ----------- 55 | MIT 56 | 57 | -------------------------------------------------------------------------------- /config.sample.coffee: -------------------------------------------------------------------------------- 1 | Banking = require 'banking' 2 | 3 | module.exports = 4 | spreadsheetId: '1UVPZEttwS8SdRpJAU9u_2dR12NRYKcWWJoQ6HHaVCNQ' 5 | banks: [ 6 | { 7 | name: 'BofA' 8 | object: Banking( 9 | fid: 5959 10 | fidOrg: 'HAN' 11 | url: 'https://eftx.bankofamerica.com/eftxweb/access.ofx' 12 | bankId: '121000358' 13 | user: 'vasya' 14 | password: 'kewlhax0r' 15 | accId: '000999999999' 16 | accType: 'CHECKING' 17 | ofxVer: 103 18 | app: 'QWIN' 19 | appVer: '2300' 20 | ) 21 | },{ 22 | name: 'AmEx' 23 | object: Banking( 24 | fid: 3101 25 | fidOrg: 'AMEX' 26 | url: 'https://online.americanexpress.com/myca/ofxdl/desktop/desktopDownload.do?request_type=nl_ofxdownload' 27 | user: 'vasya' 28 | password: 'el1tErUlEz' 29 | accId: '379705559226992' 30 | accType: 'CREDITCARD' 31 | ofxVer: 103 32 | app: 'QWIN' 33 | appVer: '1700' 34 | ) 35 | },{ 36 | name: 'Citibank' 37 | object: Banking( 38 | fid: 24909 39 | fidOrg: 'Citigroup' 40 | url: 'https://www.accountonline.com/cards/svc/CitiOfxManager.do' 41 | user: 'vasyapupkin' 42 | password: 'w1feKn0wZ' 43 | accId: '4100777776666633' 44 | accType: 'CREDITCARD' 45 | ofxVer: 103 46 | app: 'QWIN' 47 | appVer: '1700' 48 | ) 49 | } 50 | ] 51 | 52 | -------------------------------------------------------------------------------- /index.coffee: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env coffee 2 | Promise = require 'bluebird' 3 | goog = require 'googleapis' 4 | util = require 'util' 5 | colors = require 'colors/safe' 6 | fs = Promise.promisifyAll( require( 'fs' ), suffix: 'A' ) 7 | moment = require 'moment' 8 | authlib = require './lib/auth' 9 | parse = require './lib/parse' 10 | u = require './lib/utils' 11 | 12 | config = require './config' 13 | 14 | argv = require 'yargs' 15 | .usage( 'Usage: $0 [options]') 16 | .alias( 'f', 'file') 17 | .nargs( 'f', 1) 18 | .describe( 'f', 'Load OFX transactions from file, requires --bank') 19 | .alias( 'b', 'bank') 20 | .nargs( 'b', 1) 21 | .describe( 'b', 'Bank short name for the first column of transaction log' + 22 | ' (only for loading from file)') 23 | .implies( 'f', 'b') 24 | .implies( 'b', 'f') 25 | .alias( 's', 'start') 26 | .nargs( 's', 1) 27 | .describe( 's', 'Start date (default - first of the current month)') 28 | .alias( 'e', 'end') 29 | .nargs( 'e', 1) 30 | .describe( 'e', 'End date (default - today)') 31 | .epilogue( 'Dates are in YYYYMMDD format') 32 | .help() 33 | .argv 34 | 35 | # Promisify Google APIs 36 | sheetApi = Promise.promisifyAll goog.sheets('v4').spreadsheets, suffix: 'A' 37 | valueApi = Promise.promisifyAll goog.sheets('v4').spreadsheets.values, suffix: 'A' 38 | 39 | ofx_message = (bankname, msg) -> 40 | fn = switch msg.SEVERITY 41 | when 'INFO' then u.info 42 | when 'WARN' then u.warn 43 | when 'ERROR' then u.error 44 | else u.error 45 | fn( "#{bankname} message: " + msg.MESSAGE ) 46 | 47 | # Create Google API requests to create Sheets 48 | create_missing_sheets = (s) -> 49 | newsheets = {} 50 | r = [] 51 | for t in s.transactions 52 | unless s.sheets[t.shname] || newsheets[t.shname] 53 | newsheets[t.shname] = yes 54 | for shname in Object.keys newsheets 55 | r.push( addSheet: properties: title: shname ) 56 | return r 57 | 58 | # Create data for Google API request to append cells to the sheet 59 | create_table_value = (t) -> 60 | values = [] 61 | values.push( userEnteredValue: stringValue: t.name ) 62 | values.push( userEnteredValue: stringValue: t.id ) 63 | values.push( userEnteredValue: stringValue: t.date ) 64 | values.push( userEnteredValue: stringValue: t.type ) 65 | values.push( userEnteredValue: numberValue: t.amount ) 66 | values.push( userEnteredValue: stringValue: t.trname ) 67 | values.push( userEnteredValue: stringValue: t.category ) if t.category 68 | return values: values 69 | 70 | # Read OFX file (usually called Web Connect or something) 71 | # or HTML file from Amazon Store card account and return 72 | # the array with transactions 73 | read_data = (name, file, dates) -> 74 | u.info "Reading data from #{file} for #{name} between " + 75 | "#{dates.start} and #{dates.end}" 76 | fs.readFileA( file, 'utf8' ) 77 | .then (data) -> 78 | if /\.html?$/.test file 79 | return parse.html_parse( data ) 80 | else 81 | return parse.ofx_parse( data ) 82 | .then (parsed) -> 83 | return name: name, transactions: parse.parse( parsed.body ) 84 | .then (bank) -> 85 | return parse.parse_transactions bank.name, bank.transactions, dates, [] 86 | 87 | _fetch = (bank, dates, cb) -> 88 | u.info "Fetching online data from #{bank.name} between " + 89 | "#{dates.start} and #{dates.end}" 90 | bank.object.getStatement( 91 | start: dates.start 92 | end: dates.end 93 | (err, res) -> 94 | if err 95 | cb( res ) 96 | else 97 | #console.dir( res.body, depth: null ) if bank.name == 'Chase' 98 | msg = parse.get_msg( res.body ) 99 | if msg 100 | ofx_message( bank.name, msg ) 101 | cb( null, {transactions: parse.parse( res.body ), name: bank.name} ) 102 | ) 103 | fetch = Promise.promisify _fetch 104 | 105 | # Fetch OFX data from online bank and return the array with transactions 106 | fetch_data = (banks, dates) -> 107 | return Promise.map( banks, (bank) -> fetch( bank, dates ) ) 108 | .then (fbanks) -> 109 | parsed = [] 110 | for bank in fbanks 111 | # Checking dates again seems to be stupid, but it's not 112 | parsed = parse.parse_transactions bank.name, bank.transactions, dates, parsed 113 | return parsed 114 | 115 | # Retrieves transactions either from the file or from online sources 116 | # depending on command line options 117 | get_data = () -> 118 | dates = {} 119 | dates.start = argv.start || moment().format('YYYYMM01') 120 | dates.end = argv.end || moment().format('YYYYMMDD') 121 | u.info "Selected date range: #{dates.start} - #{dates.end}" 122 | if argv.file 123 | return read_data( argv.bank, argv.file, dates ) 124 | else 125 | return fetch_data( config.banks, dates ) 126 | 127 | # Main entry point 128 | # Authenticate to Google and run the file/online data fetch and then 129 | # upload the data to the Google Sheets 130 | # Each Promise 'then' step gets a state record passed to it: 131 | # sheets: hash of sheet records mapping sheet name to sheet ID 132 | # transactions: array of transaction records (see `parse.coffee`) 133 | # reqs: array of outgoing API requests for the next step 134 | authlib.run_authenticated (auth) -> 135 | Promise.join( 136 | # get the list of sheets in the spreadsheet 137 | sheetApi.getA( { auth: auth, spreadsheetId: config.spreadsheetId} ), 138 | # get financial data 139 | get_data(), 140 | (sheets, transactions) -> 141 | shs = {} 142 | for s in sheets.sheets 143 | shs[s.properties.title] = id: s.properties.sheetId 144 | return sheets: shs, transactions: transactions, reqs: [] 145 | ) 146 | .then (s) -> 147 | s.reqs = create_missing_sheets s 148 | return s 149 | .then (s) -> 150 | # create sheets if necessary, fall through if not 151 | if s.reqs.length 152 | u.info "Will create #{s.reqs.length} new sheets" 153 | return sheetApi.batchUpdateA( 154 | auth: auth 155 | spreadsheetId: config.spreadsheetId 156 | resource: requests: s.reqs 157 | ) 158 | .then (add) -> 159 | for r in add.replies 160 | u.info "Sheet #{r.addSheet.properties.title} created" 161 | # update sheets table with new IDs 162 | s.sheets[r.addSheet.properties.title] = id: r.addSheet.properties.sheetId 163 | s.reqs = [] 164 | return s 165 | else 166 | return s 167 | .then (s) -> 168 | # Dedup operation: 169 | # read transaction IDs of the already existing rows 170 | # this is the column 'B' of each monthly sheet 171 | ranges = Object.keys(s.sheets) 172 | .filter((v) -> /\d{2}-20\d{2}/.test(v)) 173 | .map((v) -> "#{v}!B:B") 174 | return valueApi.batchGetA( 175 | auth: auth 176 | spreadsheetId: config.spreadsheetId 177 | ranges: ranges 178 | valueRenderOption: 'UNFORMATTED_VALUE' 179 | majorDimension: 'COLUMNS' 180 | ) 181 | .then (res) -> 182 | # Build lookup tables of already existing transaction IDs 183 | for v in res.valueRanges 184 | sheet = eval( v.range.split('!')[0] ) 185 | vals = if v.values then v.values[0] else [] 186 | s.sheets[sheet].keys = 187 | vals.reduce( 188 | (acc, i) -> 189 | acc[i] = true 190 | return acc 191 | , {} 192 | ) 193 | return s 194 | .then (s) -> 195 | # insert transactions, removing duplicates using IDs retrieved previously 196 | for t in s.transactions 197 | unless s.sheets[t.shname]?.keys[t.id] 198 | s.sheets[t.shname].rows ?= [] 199 | s.sheets[t.shname].rows.push( create_table_value t ) 200 | u.info "Adding #{t.name} for #{t.amount} - #{t.trname} to #{t.shname}" 201 | else 202 | u.warn "Transaction #{t.id} for #{t.name} amt: #{t.amount} " + 203 | "already exists" 204 | for shname, v of s.sheets 205 | if v?.rows 206 | s.reqs.push( 207 | appendCells: 208 | sheetId: v.id 209 | rows: v.rows 210 | fields: 'userEnteredValue' 211 | ) 212 | if s.reqs.length 213 | return sheetApi.batchUpdateA( 214 | auth: auth 215 | spreadsheetId: config.spreadsheetId 216 | resource: requests: s.reqs 217 | ) 218 | .then (res) -> 219 | # Unfortunately, batchUpdate of multiple sheets is a fire and forget 220 | # operation: in case of success `res` contains only spreadsheet ID 221 | # and empty `replies` array. How convenient 222 | console.dir res, depth: null 223 | .catch (err) -> 224 | u.error "API error: " + util.inspect( err, depth: null ) 225 | .finally () -> u.info "Done!" 226 | 227 | -------------------------------------------------------------------------------- /lib/ai.coffee: -------------------------------------------------------------------------------- 1 | 2 | patterns = [ 3 | p: /CAFF?E/i 4 | c: 'Dining' 5 | , 6 | p: /restaurant/i 7 | c: 'Dining' 8 | , 9 | p: /clipper service/i 10 | c: 'Commute' 11 | , 12 | p: /caltrain/i 13 | c: 'Commute' 14 | , 15 | p: /pivotal labs/i 16 | c: 'Services' 17 | , 18 | p: /safeway/i 19 | c: 'Food' 20 | , 21 | p: /costco/i 22 | c: 'Food' 23 | , 24 | p: /sigona/i 25 | c: 'Food' 26 | , 27 | p: /comcast/i 28 | c: 'Internet' 29 | , 30 | p: /whole foods/i 31 | c: 'Food' 32 | , 33 | p: /klwines/i 34 | c: 'Wine' 35 | , 36 | p: /Check/i 37 | a: 55 38 | c: 'Services' 39 | , 40 | p: /pgande/i 41 | c: 'Utilities' 42 | , 43 | p: /Finance charge/i 44 | c: 'Fees' 45 | , 46 | p: /Doordash/i 47 | c: 'Doordash' 48 | , 49 | p: /robert's market/i 50 | c: 'Food' 51 | , 52 | p: /theater/i 53 | c: 'Entertainment' 54 | , 55 | p: /fandango/i 56 | c: 'Entertainment' 57 | , 58 | p: /netflix/i 59 | c: 'Entertainment' 60 | , 61 | p: /linkedin/i 62 | c: 'Services' 63 | , 64 | p: /pharmacy/i 65 | c: 'Pharmacy' 66 | ] 67 | 68 | module.exports = 69 | # transaction record: 70 | # name: bank name as defined in the config file or on the command line 71 | # type: DEBIT or CREDIT 72 | # id: supposedly unique ID from financial institution 73 | # date: date in 'YYYYMMDD' format 74 | # amount: transaction amount as positive or negative real number 75 | # trname: transaction name 76 | # trmemo: memo (could be name or phone number or virtually anything) 77 | # shname: name of the sheet (MM-YYYY) 78 | # 79 | # returns string to put in the column 'G' 80 | classify: (transaction) -> 81 | for p in patterns 82 | if p.p.test transaction.trname 83 | if p.a 84 | if p.a == transaction.amount 85 | return p.c 86 | else 87 | return p.c 88 | return null 89 | -------------------------------------------------------------------------------- /lib/auth.coffee: -------------------------------------------------------------------------------- 1 | Promise = require 'bluebird' 2 | fs = require 'fs' 3 | readline = require 'readline' 4 | goog = require 'googleapis' 5 | googAuth = require 'google-auth-library' 6 | u = require './utils' 7 | 8 | scopes = ['https://www.googleapis.com/auth/spreadsheets'] 9 | token_dir = './' 10 | token_path = token_dir + 'hackerwallet-token.json' 11 | 12 | # This file is mostly sample provided by Google 13 | 14 | # Authenticate to google and then run the supplied callback 15 | module.exports.run_authenticated = (cb) -> 16 | # Load client secrets from a local file. 17 | fs.readFile 'client_secret.json', (err, content) -> 18 | if (err) 19 | u.error( 'Error loading client secret file: ' + err ) 20 | return 21 | # Authorize a client with the loaded credentials, then call the 22 | # Google Sheets API. 23 | authorize JSON.parse(content), cb 24 | 25 | 26 | authorize = (creds, cb) -> 27 | clientSecret = creds.installed.client_secret 28 | clientId = creds.installed.client_id 29 | redirectUrl = creds.installed.redirect_uris[0] 30 | auth = new googAuth() 31 | oauth2Client = new auth.OAuth2(clientId, clientSecret, redirectUrl) 32 | 33 | # Check if we have previously stored a token. 34 | fs.readFile token_path, (err, token) -> 35 | if (err) 36 | getNewToken(oauth2Client, cb) 37 | else 38 | oauth2Client.credentials = JSON.parse(token) 39 | cb(oauth2Client) 40 | 41 | # Get and store new token after prompting for user authorization, and then 42 | # execute the given callback with the authorized OAuth2 client. 43 | # 44 | # @param {google.auth.OAuth2} oauth2Client The OAuth2 client to get token for. 45 | # @param {getEventsCallback} callback The callback to call with the authorized 46 | # client. 47 | getNewToken = (oauth2Client, callback) -> 48 | authUrl = oauth2Client.generateAuthUrl 49 | access_type: 'offline' 50 | scope: scopes 51 | u.info 'Authorize this app by visiting this url: ' + authUrl 52 | rl = readline.createInterface 53 | input: process.stdin 54 | output: process.stdout 55 | rl.question 'Enter the code from that page here: ', (code) -> 56 | rl.close() 57 | oauth2Client.getToken code, (err, token) -> 58 | if (err) 59 | u.error( 'Error while trying to retrieve access token: ' + err ) 60 | return 61 | oauth2Client.credentials = token 62 | storeToken token 63 | callback oauth2Client 64 | 65 | # Store token to disk be used in later program executions. 66 | # 67 | # @param {Object} token The token to store to disk. 68 | storeToken = (token) -> 69 | try 70 | fs.mkdirSync token_dir 71 | catch err 72 | if (err.code != 'EEXIST') 73 | throw err 74 | fs.writeFile token_path, JSON.stringify(token) 75 | u.info 'Token stored to ' + token_path 76 | 77 | -------------------------------------------------------------------------------- /lib/parse.coffee: -------------------------------------------------------------------------------- 1 | util = require 'util' 2 | crypto = require 'crypto' 3 | Promise = require 'bluebird' 4 | opath = require 'object-path' 5 | jquery = require 'cheerio' 6 | Banking = require 'banking' 7 | u = require './utils' 8 | ai = require './ai' 9 | 10 | path = [ "OFX.CREDITCARDMSGSRSV1.CCSTMTTRNRS.CCSTMTRS.BANKTRANLIST.STMTTRN", 11 | "OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST.STMTTRN" ] 12 | msgpath = "OFX.SIGNONMSGSRSV1.SONRS.STATUS" 13 | 14 | date_re = /(\d{4})(\d{2})(\d{2})/ 15 | 16 | parsedate = (d) -> 17 | m = d.match date_re 18 | if m 19 | return date: "#{m[2]}/#{m[3]}/#{m[1]}", sheetname: "#{m[2]}-#{m[1]}" 20 | return d 21 | 22 | date_between = (date, dates) -> 23 | d = date.slice( 0, 8 ) 24 | return d >= dates.start and d <= dates.end 25 | 26 | parse_transaction = (name, transaction, dates) -> 27 | t = transaction 28 | return if t 29 | if date_between( t.DTPOSTED, dates ) 30 | d = parsedate t.DTPOSTED 31 | name: name 32 | type: t.TRNTYPE 33 | id: t.FITID || t.REFNUM 34 | date: d.date 35 | amount: t.TRNAMT 36 | trname: t.NAME 37 | trmemo: t.MEMO || "" 38 | shname: d.sheetname 39 | else 40 | u.warn "Transaction for #{name} for #{t.TRNAMT} skipped due to the date range" 41 | null 42 | else 43 | u.error( "Can't parse transaction: #{name} " + 44 | util.inspect( t, depth: null ) ) 45 | null 46 | 47 | amazondate = (d) -> 48 | d = new Date(Date.parse(d)) 49 | return d.getFullYear() + 50 | ("00" + (d.getMonth() + 1)).slice(-2) + 51 | ("00" + d.getDate()).slice(-2) 52 | 53 | # creates an idempotent signature of an object 54 | chksum = (val) -> 55 | hash = crypto.createHash('sha1') 56 | for k,v of val 57 | hash.update( "" + v ) 58 | return hash.digest('hex') 59 | 60 | # Converts Amazon Store Card transaction dumped from HTML to OFX 61 | amazon2ofx = (at) -> 62 | DTPOSTED: amazondate(at.TRANS_DATE) 63 | TRNTYPE: 'DEBIT' 64 | REFNUM: at.REF_NUM || chksum( at ) 65 | TRNAMT: - (at.TRANS_AMOUNT - 0.0) 66 | NAME: at.TRANS_DESC 67 | 68 | # Parse string with OFX data 69 | _ofx_parse = (data, cb) -> Banking.parse( data, (res) -> cb( null, res ) ) 70 | 71 | module.exports = 72 | # retrieve transactions from the body of the response and always return 73 | # an array 74 | parse: (body) -> 75 | # first test if this is an array already, then it's probably from HTML 76 | return body if Array.isArray body 77 | # try credit card or bank statement, they are mutually exclusive 78 | # and only one will work 79 | res = opath.get(body, path[0]) || opath.get(body, path[1]) 80 | return if Array.isArray res then res else [res] 81 | # 82 | # Convert statement OFX transactions into an array of our transaction records 83 | parse_transactions: (name, transactions, dates, acc) -> 84 | for t in transactions 85 | p = parse_transaction( name, t, dates ) 86 | if p 87 | p.category = ai.classify( p ) 88 | acc.push p 89 | return acc 90 | 91 | ofx_parse: Promise.promisify _ofx_parse 92 | 93 | # Parses HTML saved from Amazon Store Card account 94 | html_parse: (data) -> 95 | $ = jquery.load( data ) 96 | json = $('#completedBillingActivityJSONArray').val() 97 | arr = JSON.parse( json ) 98 | if arr 99 | return Promise.resolve( 100 | header: "" 101 | body: arr.map(amazon2ofx) 102 | xml: json # well, not really, but for compatibility with ofx_parse 103 | ) 104 | else 105 | return Promise.reject( "No data in HTML file" ) 106 | 107 | # returns OFX message record 108 | # CODE: 'numeric code' 109 | # SEVERITY: 'ERROR' or else 110 | # MESSAGE: 'text message' 111 | get_msg: (body) -> 112 | msg = opath.get( body, msgpath ) 113 | if msg 114 | msg.MESSAGE ?= "" 115 | msg.MESSAGE += " CODE: #{msg.CODE}" 116 | return msg 117 | -------------------------------------------------------------------------------- /lib/utils.coffee: -------------------------------------------------------------------------------- 1 | colors = require 'colors/safe' 2 | 3 | module.exports = 4 | error: (msg) -> console.log(colors.red(msg)) 5 | warn: (msg) -> console.log(colors.yellow(msg)) 6 | info: (msg) -> console.log(colors.white(msg)) 7 | 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackerwallet", 3 | "version": "1.0.0", 4 | "description": "Poll banks for current transactions and update Google Sheets", 5 | "main": "index.coffee", 6 | "scripts": { 7 | "app": "./node_modules/.bin/coffee index.coffee" 8 | }, 9 | "author": "Kirill Pertsev ", 10 | "license": "MIT", 11 | "dependencies": { 12 | "banking": "https://github.com/kika/banking.js.git#clientuid", 13 | "bluebird": "^3.4.6", 14 | "cheerio": "^0.22.0", 15 | "coffee-script": "^1.10.0", 16 | "colors": "^1.1.2", 17 | "google-auth-library": "^0.9.8", 18 | "googleapis": "^12.4.0", 19 | "moment": "^2.15.0", 20 | "object-path": "^0.11.2", 21 | "readline": "^1.3.0", 22 | "yargs": "^5.0.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kika/hackerwallet/196ed6599650f68792175ae2557c7b93050ede85/screenshot.png --------------------------------------------------------------------------------