banker
Version:
Downloads transactions and balances from online banking websites using Zombie.js.
378 lines (318 loc) • 14.2 kB
JavaScript
var async = require('async'),
BankSession = require('./base'),
fs = require('fs'),
path = require('path'),
qif = require('../parsers/qif'),
util = require('util');
// Zombie.js / jsdom don't populate the document.forms and document[formName]
// properties. The Fiserv JavaScript code uses these properties pretty
// extensively.
var populateDocumentForms = fs.readFileSync(
path.join(
__dirname, '..', 'browser', 'populate-document-forms.js'
)).toString();
function FiservNewSession(config) {
var self = this;
BankSession.call(self, config);
var urlBase = 'https://www.financial-net.com/' + self.config.bankName;
self.loginUrl = urlBase + '/security/OlbCmdSmn000';
self.mainUrl = urlBase + '/banking/OlbCmdShb041';
// These pages take a while to fully load due to all the ASP.NET JavaScript
// stuff going on after the HTML loads.
self.waitOptions = {
duration : 10000
};
}
util.inherits(FiservNewSession, BankSession);
FiservNewSession.prototype.info = function() {
return {
description : 'Credit unions handled by the Fiserv software (https://www.financial-net.com/* and possibly others)',
configItems : {
driver : '"fiserv-new"',
bankName : 'Bank name (appears in URL as https://www.financial-net.com/BANKNAME/...)',
username : 'Online banking username',
password : 'Online banking password',
securityQuestions : 'Hash of security questions and answers',
passmarkName : 'The "alt" attribute of your passmark image (optional; will be validated if given)'
},
accountItems : {
code : 'The account code (*1234=56) listed in the Account column'
}
};
};
FiservNewSession.prototype.login = function(cb) {
var self = this;
if (self.loggedIn) {
self.log('already logged in');
cb(null);
return;
}
self.log('visiting login page');
self.browser.visit(self.loginUrl, function() {
if (!self.pageLoaded('login', cb)) return;
var usernameSel = '#body_body_txtUserName',
field = self.browser.field(usernameSel);
if (!field) {
cb(new Error('Could not find login ID field.'));
return;
}
self.log('sending username');
self.browser.fill(usernameSel, self.config.username);
self.browser.once('request', function(request) {
// Thanks, Ballmer
request.params['ctl00$ctl00$body$body$btnSubmit'] = 'Logon';
});
self.browser.query('#form1').submit();
self.browser.wait(self.waitOptions, function() {
if (!self.pageLoaded('afterLogin', cb)) return;
var question = self.browser.text('#body_body_lblQuestion');
if (question) {
self.log('got security question: ' + question);
if (self.config.securityQuestions) {
var answer = self.config.securityQuestions[question];
if (answer) {
self.log('answering security question');
self.browser.fill('#body_body_txtAnswer', answer);
self.browser.once('request', function(request) {
// Thanks, Ballmer
request.params['ctl00$ctl00$body$body$btnOK'] = 'OK';
});
self.browser.query('#form1').submit();
self.browser.wait(self.waitOptions, function() {
if (!self.pageLoaded('afterQuestion', cb)) return;
self._enterPassword(cb);
});
} else {
cb(new Error('Missing answer to security question: ' + question));
return;
}
} else {
cb(new Error('Missing config.securityQuestions'));
return;
}
} else {
self._enterPassword(cb);
}
});
});
};
FiservNewSession.prototype._enterPassword = function(cb) {
var self = this;
var passwordSel = '#body_body_txtPassword',
field = self.browser.field(passwordSel);
if (!field) {
cb(new Error('Could not find password field.'));
return;
}
if (self.config.securityPhrase) {
self.log('checking security phrase');
var phrase = self.browser.text('#body_body_lblPhrase');
if (!phrase || phrase !== self.config.securityPhrase) {
cb(new Error('Invalid security phrase: expected "' + self.config.securityPhrase
+ '" but found "' + phrase + '"'));
return;
}
}
self.log('sending password');
self.browser.fill(passwordSel, self.config.password);
self.browser.once('request', function(request) {
// Thanks, Ballmer
request.params['ctl00$ctl00$body$body$btnSubmit'] = 'OK';
});
self.browser.query('#form1').submit();
self.browser.wait(self.waitOptions, function() {
if (!self.pageLoaded('afterPassword', cb)) return;
self.log('visiting main page');
self.browser.visit(self.mainUrl, function() {
if (!self.pageLoaded('main', cb)) return;
var link = null;
try {
link = self.browser.link('EXIT');
} catch (err) { }
if (!link || !link.href) {
cb(new Error(self.browser.alertMessage || 'Cannot find EXIT link.'));
return;
}
self.loggedIn = true;
self.log('logged in');
cb(null);
});
});
};
FiservNewSession.prototype.getTransactions = function(account, cb) {
var self = this;
self.log('visiting main page');
self.browser.visit(self.mainUrl, function() {
if (!self.pageLoaded('mainForAccount', cb)) return;
self.browser.evaluate(populateDocumentForms);
var link = null,
links = self.browser.querySelectorAll('td.SUMTABL a');
for (var i = 0; i < links.length; i++) {
if (self.browser.text(links[i]) == account.code) {
link = links[i];
break;
}
}
if (!link) {
cb(new Error('Cannot find account with code "' + account.code + '".'));
return;
}
self.log('clicking account link (code "' + account.code + '")');
self.browser.click(link);
self.browser.wait(self.waitOptions, function() {
if (!self.pageLoaded('account', cb)) return;
var link = null;
try {
link = self.browser.link('Export');
} catch (err) { }
if (!link) {
cb(new Error('Cannot find Export link on account page.'));
return;
}
var data = {
checkImages : {},
balances : {}
};
var headers = self.browser.querySelectorAll('table.sortable th.SUMTABR'),
cells = self.browser.querySelectorAll('table.sortable td.SUMTABR');
for (var i = 0; i < headers.length; i++) {
if (self.browser.text(headers[i]) == 'Balance') {
data.balances.fromList = self.getBalance(cells[i]);
break;
}
}
var sel = 'div.window-body td.LEFT, div.window-body td.RIGHT';
cells = self.browser.querySelectorAll(sel);
for (var i = 0; i < cells.length; i++) {
switch (self.browser.text(cells[i])) {
case 'Actual Balance':
try {
data.balances.actual = self.getBalance(cells[i + 1]);
} catch (err) {
// sometimes this is *****, which is not a valid BigNumber
self.log('failed to set actual balance: ' + err.message);
}
break;
case 'Available Balance':
try {
data.balances.available = self.getBalance(cells[i + 1]);
} catch (err) {
// sometimes this is *****, which is not a valid BigNumber
self.log('failed to set available balance: ' + err.message);
}
break;
}
}
var checkLinks = self.browser.querySelectorAll('a[target=check]');
async.eachSeries(checkLinks, function(checkLink, next) {
var checkNum = self.browser.text(checkLink);
if (data.checkImages[checkNum]) {
self.log('already got images for check #' + checkNum);
next(null);
} else {
self.log('getting images for check #' + checkNum);
data.checkImages[checkNum] = {};
self.enableImages(function(request, response) {
if (response.statusCode == 200) {
var imageData = 'data:'
+ response.headers['content-type'].split(';')[0]
+ ';base64,' + response.body.toString('base64');
if (/sessionParameterName=ImageF/.test(response.url)) {
data.checkImages[checkNum]['Check front'] = imageData;
} else if (/sessionParameterName=ImageB/.test(response.url)) {
data.checkImages[checkNum]['Check back'] = imageData;
} else {
self.log('unrecognized check image: ' + response.url);
}
}
}, function(disableImages) {
self.browser.click(checkLink, function() {
if (!self.pageLoaded('check' + checkNum, cb)) return;
// the check page opens in a new window
self.browser.close('check');
disableImages();
});
}, function() {
next();
});
}
}, function(err) {
self.log('clicking Export link');
self.browser.click(link, function() {
if (!self.pageLoaded('accountExport', cb)) return;
var moreIndex = 1;
function loadMoreOrGetQIF(i) {
if (i > 0) {
self.log('getting more transactions');
self.browser.click('input[name=moreButton]');
self.browser.wait(self.waitOptions, function() {
if (!self.pageLoaded('more' + (moreIndex++), cb)) return;
loadMoreOrGetQIF(i - 1);
});
} else {
self.browser.evaluate(populateDocumentForms);
self.browser.evaluate('document.forms[0].allbox.checked = true; CheckAll();');
self.log('getting QIF');
var button = null;
try {
button = self.browser.querySelector('input[name=qifButton]');
} catch (err) {
cb(new Error(
'Cannot find Export button.'));
return;
}
self.browser.click(button);
self.browser.wait(self.waitOptions, function() {
if (!self.pageLoaded('qif', cb)) return;
qif.parse(self.browser.response.body, function(err, transactions) {
if (err) {
cb(err);
} else {
data.transactions = transactions;
self._processData(data, cb);
}
});
});
}
}
loadMoreOrGetQIF(+(account.loadMoreTransactions || 0));
});
});
});
});
};
FiservNewSession.prototype._processData = function(data, cb) {
var self = this;
data.transactions.forEach(function(transaction) {
if (/id:|ref:|t(x|rans(action)?) *id/i.test(transaction.memo)) {
transaction.sourceId = transaction.memo;
delete transaction.memo;
} else {
transaction.sourceId = self.makeTransactionId(transaction);
}
var match = transaction.description.match(/^SHARE DRAFT #\s*([0-9]+)\s*$/);
if (match && data.checkImages[match[1]]) {
transaction.images = data.checkImages[match[1]];
delete data.checkImages[match[1]];
}
});
var keys = Object.keys(data.checkImages);
if (keys.length) {
self.log('check images with unmatched transactions: ' + keys.join(', '));
}
delete data.checkImages;
cb(null, data);
};
FiservNewSession.prototype.logout = function(cb) {
var self = this;
if (!cb) cb = function() { };
if (!self.loggedIn) {
self.log('may not be logged in');
}
self.log('visiting logout page');
self.browser.visit(self.logoutUrl, function() {
if (!self.pageLoaded('logout', cb)) return;
cb(null);
});
};
module.exports = FiservNewSession;