├── .gitignore ├── package.json ├── examples ├── categories.js ├── create-category.js ├── transactions.js └── expenses-by-category.js ├── lib └── utils.js ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meniga", 3 | "version": "0.5.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "bluebird": "^2.10.1", 13 | "co": "^4.6.0", 14 | "lodash": "^3.10.1", 15 | "moment": "^2.10.6", 16 | "request": "^2.64.0", 17 | "traverse": "^0.6.6" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/categories.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This example prints all categories belonging to the user to stdout in a JSON format. 4 | 5 | let bluebird = require('bluebird'); 6 | let co = require('co'); 7 | let _ = require('lodash'); 8 | let moment = require('moment'); 9 | 10 | let MenigaClient = require('../index'); 11 | 12 | let username = process.env.MENIGA_USERNAME; 13 | let password = process.env.MENIGA_PASSWORD; 14 | if (!username || !password) { 15 | console.error('You need to configure both env vars: MENIGA_USERNAME and MENIGA_PASSWORD'); 16 | } 17 | 18 | co(function* () { 19 | try { 20 | let menigaClient = new MenigaClient(); 21 | let authed = yield menigaClient.auth(username, password); 22 | let categories = yield menigaClient.getUserCategories(); 23 | console.log(JSON.stringify(categories)); 24 | } catch (err) { 25 | console.error('got err:', err); 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /examples/create-category.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This creates a category, allowing for the configuration of categoryType and whether 4 | // the category is fixed or not. 5 | 6 | let bluebird = require('bluebird'); 7 | let co = require('co'); 8 | let _ = require('lodash'); 9 | let moment = require('moment'); 10 | 11 | let MenigaClient = require('../index'); 12 | 13 | let username = process.env.MENIGA_USERNAME; 14 | let password = process.env.MENIGA_PASSWORD; 15 | if (!username || !password) { 16 | console.error('You need to configure both env vars: MENIGA_USERNAME and MENIGA_PASSWORD'); 17 | } 18 | 19 | co(function* () { 20 | try { 21 | let menigaClient = new MenigaClient(); 22 | let authed = yield menigaClient.auth(username, password); 23 | let success = yield menigaClient.createUserCategory({ 24 | categoryType: '0', 25 | isFixedExpenses: true, 26 | name: 'Húsfélagsgjöld', 27 | parentId: '13' 28 | }); 29 | console.log(success); 30 | } catch (err) { 31 | console.log(err); 32 | console.log(err.stack) 33 | } 34 | }); -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let moment = require('moment'); 4 | let traverse = require('traverse'); 5 | let _ = require('lodash'); 6 | 7 | const ASPNET_DATE_REGEX = new RegExp('/Date\\(([0-9]+)([+-])?([0-9]+)?\\)/'); 8 | 9 | function fromAspNetDates(root, useMomentDates) { 10 | traverse(root).forEach(function (node) { 11 | if (_.isString(node)) { 12 | let matched = node.match(ASPNET_DATE_REGEX); 13 | if (matched) { 14 | // TODO: Respect the time-zone. 15 | let unixTime = Number(matched[1]) / 1000; 16 | let momentTime = moment.unix(unixTime); 17 | let date = useMomentDates ? momentTime : momentTime.toDate(); 18 | this.update(date); 19 | } 20 | } 21 | }); 22 | return root; 23 | } 24 | 25 | function toAspNetDates(root) { 26 | traverse(root).forEach(function (node) { 27 | if (_.isDate(node) || moment.isMoment(node)) { 28 | let unixTime = _.isDate(node) ? node.getTime() : node.unix() * 1000; 29 | this.update(`/Date(${unixTime}+0000)/`); 30 | } 31 | }); 32 | return root; 33 | } 34 | 35 | // Exports 36 | module.exports.toAspNetDates = toAspNetDates; 37 | module.exports.fromAspNetDates = fromAspNetDates; 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Meniga API 2 | > An unofficial Node API client for Meniga. 3 | 4 | ## What is this? 5 | 6 | [Meniga](https://www.meniga.com) is an Icelandic company that uses machine-learning techniques to automatically categorize your credit/debit card transactions (i.e. transportation, grocery, restaurants, coffee, etc). On top of this they provide you with a web interface to this data that aims to give you insight into how you spend your money. 7 | 8 | Unfortunately, this web interface has remained the same for ages and even though it still remains hugely useful to me I have now started exploring other ways to access this data, i.e. by communicating directly with their APIs, which are _not publicly available_. 9 | 10 | This repository aims to create an unofficial Node Meniga API client so hackers can expand on the Meniga's limited web UI. 11 | 12 | ## A word of caution 13 | 14 | Note that this project aims to provide an API client to a non-public API that _can_ change at any point without notice. This project is thus only useful for hobby projects. 15 | 16 | ## How to use? 17 | 18 | ```javascript 19 | var meniga = new MenigaClient(); 20 | meniga.auth('', '') 21 | .then(function () { 22 | return meniga.getTransactionsPage({ 23 | PeriodFrom: moment('2016-01-01'), 24 | PeriodTo: moment('2016-02-01') 25 | }); 26 | }) 27 | ``` 28 | 29 | ## Note 30 | 31 | This stuff is _only_ useful to you if you have access to Meniga which you probably don't if you're outside of Iceland. 32 | -------------------------------------------------------------------------------- /examples/transactions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This example fetches all transactions done during a given interval of days, attaching 4 | // the correct category to every transaction. It then ends by printing the transaction + category 5 | // data to stdout as a JSON array. 6 | 7 | let bluebird = require('bluebird'); 8 | let co = require('co'); 9 | let _ = require('lodash'); 10 | let moment = require('moment'); 11 | 12 | let MenigaClient = require('../index'); 13 | 14 | let username = process.env.MENIGA_USERNAME; 15 | let password = process.env.MENIGA_PASSWORD; 16 | if (!username || !password) { 17 | console.error('You need to configure both env vars: MENIGA_USERNAME and MENIGA_PASSWORD'); 18 | } 19 | 20 | co(function* () { 21 | try { 22 | let menigaClient = new MenigaClient(); 23 | let authed = yield menigaClient.auth(username, password); 24 | let categories = yield menigaClient.getUserCategories(); 25 | let categoriesByIndex = _.indexBy(categories, 'Id'); 26 | let page = 0; 27 | let transactions; 28 | let allTransactions = []; 29 | do { 30 | transactions = yield menigaClient.getTransactionsPage({ 31 | filter: { 32 | PeriodFrom: moment('2016-01-01'), 33 | PeriodTo: moment('2016-01-31') 34 | }, 35 | page: page 36 | }); 37 | _.forEach(transactions.Transactions, function (transaction) { 38 | if (_.has(categoriesByIndex, transaction.CategoryId)) { 39 | transaction.Category = categoriesByIndex[transaction.CategoryId]; 40 | } 41 | allTransactions.push(transaction); 42 | }); 43 | page++; 44 | } while (transactions.HasMorePages); 45 | console.log(JSON.stringify(allTransactions)); 46 | } catch (err) { 47 | console.error('got err:', err); 48 | } 49 | }); 50 | -------------------------------------------------------------------------------- /examples/expenses-by-category.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This example fetches statistics on your spend by category, ordered by the amount and returns 4 | // it as a JSON string. 5 | 6 | let bluebird = require('bluebird'); 7 | let co = require('co'); 8 | let _ = require('lodash'); 9 | let moment = require('moment'); 10 | 11 | let MenigaClient = require('../index'); 12 | 13 | let username = process.env.MENIGA_USERNAME; 14 | let password = process.env.MENIGA_PASSWORD; 15 | if (!username || !password) { 16 | console.error('You need to configure both env vars: MENIGA_USERNAME and MENIGA_PASSWORD'); 17 | } 18 | 19 | function createOptions(categories) { 20 | return { 21 | filter: { 22 | Type: 1, 23 | Group: 1, 24 | View: 1, 25 | Options: { 26 | IsParent: true, 27 | AccumulateCategoryExpenses: false, 28 | SkipInvertedCategories: true, 29 | GetFuture: false, 30 | FutureMonths: 6, 31 | GetAverage: false, 32 | ExcludeNonMappedMerchants: false, 33 | MaxTopMerchants: 10, 34 | IncludeSavingsInNetIncome: true, 35 | DateFormat: null, 36 | MinPieSliceValue: 1, 37 | MinSlicesInPie: 0, 38 | MaxSlicesInPie: 1000, 39 | UseAndSearchForTags: false, 40 | DisableSliceGrouping: false 41 | }, 42 | Period: '0', // '1', // '0' = this month, '1' = last month :O 43 | PeriodFrom: null, // moment('2015-01-01 00:00:00'), 44 | PeriodTo: null, // moment('2016-01-01 00:00:00'), 45 | ComparisonPeriod: null, 46 | CategoryIds: categories, 47 | AccountIds: null, 48 | AccountIdentifiers: null, 49 | Merchants: null, 50 | Tags: null 51 | } 52 | }; 53 | } 54 | 55 | function rpad(s, n) { 56 | return s + ' '.repeat(Math.max(n - s.length, 0)); 57 | } 58 | 59 | function isFixed(name, categoriesByName) { 60 | if (_.has(categoriesByName, name)) { 61 | return (name === 'Áskriftir og miðlun' || categoriesByName[name].IsFixedExpenses); 62 | } else { 63 | return false; 64 | } 65 | } 66 | 67 | function print(obj) { 68 | _.forEach(obj.transactions, data => { 69 | console.log(` ${rpad(data[0], 40)}${-data[1]} kr.`); 70 | }); 71 | console.log(` ${rpad('Total:', 40)}${-obj.total} kr.`); 72 | } 73 | 74 | co(function* () { 75 | try { 76 | let menigaClient = new MenigaClient(); 77 | let authed = yield menigaClient.auth(username, password); 78 | let categories = yield menigaClient.getUserCategories(); 79 | let allCategoryIds = _.pluck(categories, 'Id'); 80 | let categoriesByName = _.indexBy(categories, 'Name'); // we don't get the category id? 81 | let report = yield menigaClient.getTrendsReport(createOptions(allCategoryIds)); 82 | 83 | let fixed = { total: 0, transactions: [] }; 84 | let variable = { total: 0, transactions: [] }; 85 | _.forEach(report.Series.Rows, row => { 86 | let data = _.pluck(row.Columns, 'Value'); 87 | if (isFixed(data[0], categoriesByName)) { 88 | fixed.transactions.push(data); 89 | fixed.total += data[1]; 90 | } else { 91 | variable.transactions.push(data); 92 | variable.total += data[1]; 93 | } 94 | }); 95 | 96 | // print stuff: 97 | console.log('Fixed:'); 98 | print(fixed); 99 | 100 | console.log('\nVariable:'); 101 | print(variable); 102 | 103 | } catch (err) { 104 | console.error('got err:', err); 105 | console.error(err.stack); 106 | } 107 | }); 108 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let bluebird = require('bluebird'); 4 | let request = bluebird.promisify(require('request')); 5 | request = request.defaults({ jar: true }); 6 | let _ = require('lodash'); 7 | 8 | let utils = require('./lib/utils.js'); 9 | 10 | const RV_TOKEN_REGEX = new RegExp('value="([a-zA-Z0-9/+=]+)"'); 11 | const USER_AGENT = 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.101 Safari/537.36'; 12 | 13 | function MenigaClient() { 14 | this.baseUrl = 'https://www.meniga.is'; 15 | this.requestVerificationToken = null; 16 | } 17 | 18 | MenigaClient.prototype.auth = function* (username, password) { 19 | var that = this; // Goddamn it 20 | 21 | var options = { 22 | method: 'POST', 23 | url: `${this.baseUrl}/User/LogOn`, 24 | headers: { 'user-agent': USER_AGENT }, 25 | form: { culture: 'is-IS', email: username, password: password }, 26 | json: true 27 | }; 28 | const logonResponse = yield request(options).get(0); 29 | 30 | // Make the follow-up request to fetch the request verification token. 31 | // I actually have no idea if this is needed or not. 32 | options = { 33 | method: 'GET', 34 | url: this.baseUrl + logonResponse.headers['location'], 35 | headers: { 'user-agent': USER_AGENT }, 36 | json: true 37 | }; 38 | 39 | // Where art thou destructuring?! 40 | const resAndBody = yield request(options); 41 | const res = resAndBody[0]; 42 | const body = resAndBody[1]; 43 | 44 | // Parse the "__RequestVerificationToken" from the HTML body. Sawry. 45 | _.forEach(body.split('\n'), function (line) { 46 | if (line.indexOf('__RequestVerificationToken') >= 0) { 47 | let match = line.match(RV_TOKEN_REGEX); 48 | that.requestVerificationToken = match[1]; 49 | } 50 | }); 51 | 52 | return true; 53 | } 54 | 55 | var endpoints = [ 56 | { 57 | identifier: 'createUserCategory', 58 | path: '/Api/User/CreateUserCategory', 59 | params: [ 60 | { name: 'categoryType', type: 'string', description: 'expenses ("0")' }, 61 | { name: 'isFixedExpenses', type: 'boolean', description: 'whether this is fixed or variable expenses' }, 62 | { name: 'name', type: 'string', description: 'the name of the new category' }, 63 | { name: 'parentId', type: 'string', description: 'the parent category of the new category' } 64 | ] 65 | }, 66 | { 67 | identifier: 'getBudgetEquationWidget', 68 | path: '/Api/Widgets/GetBudgetEquationWidget', 69 | params: [ 70 | { name: 'period', type: 'integer', description: 'no idea...' } 71 | ] 72 | }, { 73 | identifier: 'getTransactionsPage', 74 | path: '/Api/Transactions/GetTransactionsPage', 75 | description: 'Fetches data on transactions and their categories over timespans', 76 | params: [ 77 | { name: 'page', type: 'integer', description: 'the page number to fetch' }, 78 | { name: 'transactionsPerPage', type: 'integer', description: 'the number of transactions per page' }, 79 | { name: 'filter', type: 'object', description: 'the filters to apply', subProperties: [ 80 | { name: 'PeriodFrom', type: 'datetime', description: 'lower bound timestamp' }, 81 | { name: 'PeriodTo', type: 'datetime', description: 'upper bound timestamp' }, 82 | ] } 83 | ] 84 | }, { 85 | identifier: 'getUserCategories', 86 | path: '/Api/User/GetUserCategories', 87 | description: 'Fetches data on all public categories and the ones created by the currently logged in user.', 88 | params: [] 89 | }, { 90 | identifier: 'getTrendsReport', 91 | path: '/Api/Planning/GetTrendsReport', 92 | description: 'An analytics endpoint allowing you to analyze your expenses by categories and over timespans', 93 | params: [ 94 | { name: 'filter', type: 'object', description: 'the filters to apply', subProperties: [ 95 | { name: 'View', type: 'integer', defaults: 1, description: '1 = sum over all months, 2 = group by month' }, 96 | { name: 'Type', type: 'integer', defaults: 1, description: '1 = im not sure, just use that' }, 97 | { name: 'Tags', type: 'array[string]', defaults: null, description: 'the tags to analyze, null to ignore.'}, 98 | { name: 'Period', type: 'string', defaults: '0', description: '0=this month, 1=last month, 3=last 3 months, 6=last 6 months, 12=last 12 months, -1=this year, -2=last year'}, 99 | { name: 'PeriodFrom', type: 'datetime', defaults: null, description: 'lower bound timestamp, overrides "Period".' }, 100 | { name: 'PeriodTo', type: 'datetime', defaults: null, description: 'upper bound timestamp, overrides "Period".' }, 101 | { name: 'Merchants', type: 'string', defaults: null, description: 'im not sure, null is default.' }, 102 | { name: 'Group', type: 'integer', defaults: 1, description: 'im not sure' }, 103 | { name: 'CategoryIds', type: 'array[integer]', description: 'IDs of the categories to analyze' }, 104 | { name: 'AccountIdentifiers', type: '?', defaults: null, description: '?' }, 105 | { name: 'AccountIds', type: '?', defaults: null, description: '?' }, 106 | { name: 'ComparisonPeriod', type: '?', defaults: null, description: '?' }, 107 | { name: 'Options', type: 'object', description: 'additional options to apply', subProperties: [ 108 | { name: 'AccumulateCategoryExpenses', type: 'boolean', defaults: false, description: '?' }, 109 | { name: 'DateFormat', type: '?', defaults: null, description: '?' }, 110 | { name: 'DisableSliceGrouping', type: '?', defaults: false, description: '?' }, 111 | { name: 'ExcludeNonMappedMerchants', type: '?', defaults: false, description: '?' }, 112 | { name: 'FutureMonths', type: 'integer', description: '?' }, 113 | { name: 'GetAverage', type: 'boolean', defaults: false, description: '?' }, 114 | { name: 'GetFuture', type: 'boolean', description: '?' }, 115 | { name: 'IncludeSavingsInNetIncome', type: 'boolean', defaults: true, description: '?' }, 116 | { name: 'IsParent', type: 'boolean', defaults: true, description: '?' }, 117 | { name: 'MaxSlicesInPie', type: 'integer', defaults: 10, description: '?' }, 118 | { name: 'MaxTopMerchants', type: 'integer', defaults: 10, description: '?' }, 119 | { name: 'MinPieSliceValue', type: 'integer', defaults: 1, description: '?' }, 120 | { name: 'MinSlicesInPie', type: 'integer', defaults: 5, description: '?' }, 121 | { name: 'SkipInvertedCategories', type: 'boolean', defaults: false, description: '?' }, 122 | { name: 'UseAndSearchForTags', type: 'boolean', defaults: false, description: '?' } 123 | ] } 124 | ] } 125 | ] 126 | } 127 | ]; 128 | 129 | _.forEach(endpoints, function (endpoint) { 130 | MenigaClient.prototype[endpoint.identifier] = function* (options) { 131 | var options = { 132 | method: 'POST', 133 | url: this.baseUrl + endpoint.path, 134 | body: utils.toAspNetDates(options || {}), 135 | headers: { 136 | 'user-agent': USER_AGENT, 137 | '__RequestVerificationToken': this.requestVerificationToken 138 | }, 139 | json: true, 140 | } 141 | let resAndBody = yield request(options); 142 | let res = resAndBody[0]; 143 | let body = resAndBody[1]; 144 | if (res.statusCode >= 400) { 145 | throw new Error('Non-200 response from the Meniga API: ' + res.statusCode + ' body: ', body); 146 | } 147 | 148 | return utils.fromAspNetDates(body, false); 149 | } 150 | }); 151 | 152 | // Exports. 153 | module.exports = MenigaClient; 154 | --------------------------------------------------------------------------------