├── src ├── model │ ├── tag.ts │ ├── category.ts │ ├── budget.ts │ ├── account.ts │ ├── transaction.ts │ └── provider.ts ├── util │ ├── async.ts │ ├── clock.ts │ ├── cache.ts │ ├── date.ts │ └── budget.ts ├── deferred.ts ├── auth │ ├── index.ts │ ├── session-cookies.ts │ ├── legacy-auth.ts │ └── chromedriver-auth.ts ├── model.ts ├── index.ts ├── net.ts └── core.ts ├── test ├── .eslintrc.js ├── README.md ├── test.js ├── util │ └── budget-test.ts └── integration-test.js ├── webdriver-util.js ├── .gitignore ├── package.json ├── release.py ├── .eslintrc.js ├── tsconfig.json └── README.md /src/model/tag.ts: -------------------------------------------------------------------------------- 1 | export interface IMintTag { 2 | id: number; 3 | value: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/util/async.ts: -------------------------------------------------------------------------------- 1 | export function delayMillis(millis: number) { 2 | return new Promise(resolve => { 3 | setTimeout(resolve, millis); 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /src/model/category.ts: -------------------------------------------------------------------------------- 1 | export interface IMintCategory { 2 | children?: IMintCategory[]; 3 | id: number; 4 | isL1: boolean; 5 | isStandard?: boolean; 6 | value: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/util/clock.ts: -------------------------------------------------------------------------------- 1 | export interface IClock { 2 | now(): Date; 3 | } 4 | 5 | export class Clock implements IClock { 6 | public now(): Date { 7 | return new Date(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "../eslint", 4 | ], 5 | "env": { 6 | "es6": true, 7 | "mocha": true, 8 | "amd": true 9 | }, 10 | "rules": { 11 | "no-console": "off", 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/util/cache.ts: -------------------------------------------------------------------------------- 1 | export class Cache { 2 | private readonly cache: {[key: string]: any} = {}; 3 | 4 | public async as(name: string, block: () => Promise) { 5 | const cached = this.cache[name]; 6 | if (cached) return cached as T; 7 | 8 | // TODO probably, limit cache duration 9 | const result = await block(); 10 | this.cache[name] = result; 11 | return result; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Pepper-Mint Tests 2 | 3 | ## Running Tests 4 | 5 | In order to run the tests in this project: 6 | 7 | + Create a file called `config.json` in this directory and place your 8 | username, password, and cookie for Mint in it like so: 9 | 10 | ``` 11 | { 12 | "username": "your-username@whatever.com", 13 | "password": "your-password", 14 | "cookie": "your-cookie" 15 | } 16 | ``` 17 | 18 | + Run `npm test` in the command line 19 | -------------------------------------------------------------------------------- /webdriver-util.js: -------------------------------------------------------------------------------- 1 | 2 | var webdriver = require('selenium-webdriver') 3 | , WebElementCondition = webdriver.WebElementCondition; 4 | 5 | /** 6 | * 7 | */ 8 | module.exports.elementAttrMatches = function(locator, attrName, fn, self) { 9 | return new WebElementCondition('until element[' + attrName + '] matches', 10 | function(driver) { 11 | return driver.findElement(locator).then(function(el) { 12 | return el && el.getAttribute(attrName).then(v => fn(v) ? el : null); 13 | }) 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/deferred.ts: -------------------------------------------------------------------------------- 1 | import { IMintAuth } from "./model"; 2 | 3 | export class DeferredAuth implements IMintAuth { 4 | 5 | private realAuth: IMintAuth | undefined; 6 | 7 | public resolve(auth: IMintAuth) { 8 | this.realAuth = auth; 9 | } 10 | 11 | public get cookies() { 12 | const auth = this.realAuth; 13 | if (!auth) throw new Error("Deferred auth is not yet resolved"); 14 | return auth.cookies; 15 | } 16 | 17 | public get token() { 18 | const auth = this.realAuth; 19 | if (!auth) throw new Error("Deferred auth is not yet resolved"); 20 | return auth.token; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | import { IMintAuthorizer, IMintCredentials, INetService } from "../model"; 4 | 5 | import { ChromedriverMintAuth } from "./chromedriver-auth"; 6 | import { LegacyMintAuth } from "./legacy-auth"; 7 | 8 | export class MintAuth implements IMintAuthorizer { 9 | 10 | private readonly strategies: IMintAuthorizer[]; 11 | 12 | constructor(private readonly net: INetService) { 13 | this.strategies = [ 14 | new LegacyMintAuth(net), 15 | new ChromedriverMintAuth(), 16 | ]; 17 | } 18 | 19 | public async authorize( 20 | events: EventEmitter, 21 | credentials: IMintCredentials, 22 | ) { 23 | let lastError: Error | null = null; 24 | for (const strategy of this.strategies) { 25 | try { 26 | const auth = await strategy.authorize(events, credentials); 27 | if (auth) { 28 | this.net.setAuth(auth); 29 | return auth; 30 | } 31 | } catch (e) { 32 | // fall 33 | lastError = e; 34 | } 35 | } 36 | 37 | throw lastError || new Error("Failed to authorize"); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/model/budget.ts: -------------------------------------------------------------------------------- 1 | export interface IBudgetRangeQuery { 2 | start: Date; 3 | end: Date; 4 | } 5 | 6 | export interface IBudgetLastNMonthsQuery { 7 | months: number; 8 | } 9 | 10 | export type IBudgetQuery = IBudgetRangeQuery | IBudgetLastNMonthsQuery; 11 | 12 | export interface IMintBudgetItem { 13 | /** rollover spending */ 14 | ramt: number; 15 | 16 | /** 17 | * total spent amount, including [ramt]. The amount *actually spent* 18 | * during the month this BudgetItem belongs to is: 19 | * 20 | * amt - ramt 21 | */ 22 | amt: number; 23 | 24 | /** budgeted amount */ 25 | bgt: number; 26 | 27 | /** rollover balance */ 28 | rbal: number; 29 | ex: boolean; 30 | id: number; 31 | pid: number; 32 | st: number; 33 | type: number; 34 | 35 | /** ex: 'Personal' */ 36 | catTypeFilter: string; 37 | 38 | /** the category name, as per getCategoryNameById */ 39 | category: string; 40 | cat: number; 41 | 42 | isIncome: boolean; 43 | isTransfer: boolean; 44 | isExpense: boolean; 45 | } 46 | 47 | export type IMintBudgetData = IMintBudgetItem[]; 48 | 49 | export interface IMintBudget { 50 | income: IMintBudgetData; 51 | spending: IMintBudgetData; 52 | unbudgeted: { 53 | income: IMintBudgetData; 54 | spending: IMintBudgetData; 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var PepperMint = require('../dist'); 3 | 4 | try { 5 | var config = require('./integration-config.json'); 6 | } catch (e) { 7 | // no config; don't run tests 8 | } 9 | 10 | if (config) { 11 | describe('pepper-mint', function() { 12 | describe('#Prepare(email, password, cookie)', function () { 13 | it('should login successfully', function () { 14 | this.timeout(30000); 15 | return PepperMint(config.username, config.password, config.cookie).then(mint => { 16 | assert.notEqual(null, mint); 17 | }).catch(err => { 18 | if (err) throw err; 19 | }); 20 | }); 21 | }); 22 | describe('#getTransactions()', function () { 23 | it('should return list of transactions', function () { 24 | this.timeout(30000); 25 | return PepperMint(config.username, config.password, config.cookie).then(mint => { 26 | return mint.getTransactions().then(transactions => { 27 | assert.notEqual(null, transactions); 28 | assert.notEqual(0, transactions.length); 29 | }); 30 | }).catch(err => { 31 | if (err) throw err; 32 | }); 33 | }); 34 | }); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/model/account.ts: -------------------------------------------------------------------------------- 1 | export type Status = "1" | "3"; // ? 2 | 3 | export interface IMintAccount { 4 | linkedAccountId: string | null; 5 | accountName: string; 6 | addAccountDate: number; 7 | fiLoginDisplayName: string; 8 | ccAggrStatus: number; 9 | exclusionType: string; 10 | linkedAccount: string | null; 11 | isHiddenFromPlanningTrends: boolean; 12 | isTerminal: boolean; 13 | linkCreationTime: number | null; 14 | isActive: boolean; 15 | accountStatus: Status; 16 | accountSystemStatus: "ACTIVE" | "DEAD"; 17 | lastUpdated: number; 18 | fiLastUpdated: number; 19 | yodleeAccountNumberLast4: string; 20 | isError: boolean; 21 | fiName: string; 22 | isAccountNotFound: boolean; 23 | klass: "bank" | "credit" | "invest" | "loan"; 24 | possibleLinkAccounts: []; 25 | lastUpdatedInString: string; 26 | accountTypeInt: number; 27 | currency: string; 28 | id: number; 29 | isHostAccount: boolean; 30 | value: number; 31 | fiLoginId: number; 32 | usageType: "PERSONAL" | null; 33 | interestRate: number; 34 | accountType: "bank" | "credit" | "investment" | "loan"; 35 | currentBalance: number; 36 | fiLoginStatus: "OK"; 37 | isAccountClosedByMint: boolean; 38 | 39 | /** IE: the user's preferred name for the account */ 40 | userName: string | null; 41 | yodleeName: string; 42 | closeDate: number; 43 | linkStatus: "NOT_LINKED"; 44 | accountId: number; 45 | isClosed: boolean; 46 | fiLoginUIStatus: "OK" | "FAILED" | "FAILED_NEW_MFA_CHALLENGE_REQUIRED"; 47 | yodleeAccountId: number; 48 | name: string; 49 | status: Status; 50 | } 51 | -------------------------------------------------------------------------------- /src/auth/session-cookies.ts: -------------------------------------------------------------------------------- 1 | import { IMintCredentials } from "../model"; 2 | 3 | export function extractSessionCookies( 4 | creds: IMintCredentials, 5 | ) { 6 | const { extras } = creds; 7 | if (!extras) return; 8 | 9 | const { cookies, token } = extras; 10 | 11 | if ( 12 | cookies 13 | && Array.isArray(cookies) 14 | && cookies.length 15 | ) { 16 | // newest, normal version 17 | return { cookies, token }; 18 | } 19 | 20 | if ( 21 | token 22 | && !cookies 23 | && token.includes("ius_session") 24 | ) { 25 | 26 | // attempt backwards compatibility with old `cookies` arg 27 | const tryMatch = function(regex: RegExp) { 28 | const m = regex.exec(token); 29 | if (m) return m[1]; 30 | }; 31 | 32 | return { 33 | token, 34 | cookies: [ 35 | { 36 | name: 'ius_session', 37 | value: tryMatch(/ius_session=([^;]+)/), 38 | }, 39 | { 40 | name: 'thx_guid', 41 | value: tryMatch(/thx_guid=([^;]+)/), 42 | }, 43 | ], 44 | }; 45 | 46 | } 47 | 48 | if ( 49 | token 50 | && !cookies 51 | && typeof(token) !== 'string' 52 | ) { 53 | // map of old cookies 54 | return { 55 | token, 56 | cookies: [ 57 | { name: 'ius_session', value: (token as any).ius_session }, 58 | { name: 'thx_guid', value: (token as any).thx_guid }, 59 | ], 60 | }; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/util/date.ts: -------------------------------------------------------------------------------- 1 | const MONTH_NUMBERS = { 2 | Jan: '01', 3 | Feb: '02', 4 | Mar: '03', 5 | Apr: '04', 6 | May: '05', 7 | Jun: '06', 8 | Jul: '07', 9 | Aug: '08', 10 | Sep: '09', 11 | Oct: '10', 12 | Nov: '11', 13 | Dec: '12', 14 | }; 15 | 16 | function ensureDateStringFormatted(date: string) { 17 | if (date.includes('/')) { 18 | // it's good! (probably) 19 | return date; 20 | } 21 | 22 | const parts = date.split(' '); 23 | if (parts.length !== 2) { 24 | // not something we can handle; just return as-is 25 | return date; 26 | } 27 | 28 | const month = MONTH_NUMBERS[parts[0] as keyof typeof MONTH_NUMBERS]; 29 | if (!month) { 30 | // as above 31 | return date; 32 | } 33 | 34 | const day = parts[1]; 35 | const year = new Date().getFullYear(); 36 | 37 | return month + '/' + day + '/' + year; 38 | } 39 | 40 | export function stringifyDate(date: string | Date) { 41 | if (typeof(date) === 'string') { 42 | return ensureDateStringFormatted(date); 43 | } 44 | 45 | let month: string | number = date.getMonth() + 1; 46 | if (month < 10) { 47 | month = `0${month}`; 48 | } 49 | 50 | let day: string | number = date.getDate(); 51 | if (day < 10) { 52 | day = `0${day}`; 53 | } 54 | 55 | const year = date.getFullYear(); 56 | return month + '/' + day + '/' + year; 57 | } 58 | 59 | export function firstDayOfNextMonth(date: Date) { 60 | if (date.getMonth() === 11) { 61 | return new Date(date.getFullYear() + 1, 0); 62 | } else { 63 | return new Date(date.getFullYear(), date.getMonth() + 1); 64 | } 65 | } 66 | 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | /.idea 4 | test/config.json 5 | test/integration-config.json 6 | test/test2.js 7 | 8 | # Created by https://www.gitignore.io/api/node 9 | 10 | ### Node ### 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (http://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Typescript v1 declaration files 50 | typings/ 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Optional REPL history 59 | .node_repl_history 60 | 61 | # Output of 'npm pack' 62 | *.tgz 63 | 64 | # Yarn Integrity file 65 | .yarn-integrity 66 | 67 | # dotenv environment variables file 68 | .env 69 | 70 | 71 | # End of https://www.gitignore.io/api/node 72 | 73 | # Created by https://www.gitignore.io/api/vim 74 | 75 | ### Vim ### 76 | # swap 77 | [._]*.s[a-v][a-z] 78 | [._]*.sw[a-p] 79 | [._]s[a-v][a-z] 80 | [._]sw[a-p] 81 | # session 82 | Session.vim 83 | # temporary 84 | .netrwhist 85 | *~ 86 | # auto-generated tag files 87 | tags 88 | 89 | # End of https://www.gitignore.io/api/vim 90 | 91 | dist/ 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pepper-mint", 3 | "version": "2.2.4", 4 | "description": "An unofficial API for mint.com", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "/dist" 9 | ], 10 | "scripts": { 11 | "build": "tsc -p .", 12 | "check": "npm run lint && npm run build && npm run test", 13 | "lint": "eslint -c .eslintrc.js src/**.ts", 14 | "prepublishOnly": "npm run check", 15 | "test": "mocha -r ts-node/register test/**/*-test.ts" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "http://github.com/dhleong/pepper-mint" 20 | }, 21 | "keywords": [ 22 | "mint.com", 23 | "mint", 24 | "api" 25 | ], 26 | "author": "Daniel Leong ", 27 | "license": "ISC", 28 | "bugs": { 29 | "url": "https://github.com/dhleong/pepper-mint/issues" 30 | }, 31 | "homepage": "https://github.com/dhleong/pepper-mint", 32 | "dependencies": { 33 | "chromedriver": "^88.0.0", 34 | "request": "^2.88.2", 35 | "request-promise-native": "^1.0.8", 36 | "selenium-webdriver": "^3.6.0" 37 | }, 38 | "devDependencies": { 39 | "@types/chai": "^4.2.11", 40 | "@types/mocha": "^7.0.2", 41 | "@types/node": "^12.12.31", 42 | "@types/request": "^2.48.4", 43 | "@types/request-promise-native": "^1.0.17", 44 | "@types/selenium-webdriver": "^4.0.9", 45 | "@typescript-eslint/eslint-plugin": "^2.25.0", 46 | "@typescript-eslint/eslint-plugin-tslint": "^2.25.0", 47 | "@typescript-eslint/parser": "^2.25.0", 48 | "chai": "^4.2.0", 49 | "eslint": "^6.8.0", 50 | "eslint-plugin-import": "^2.20.1", 51 | "eslint-plugin-prefer-arrow": "^1.1.7", 52 | "mocha": "^7.1.1", 53 | "tslint": "^6.1.0", 54 | "typescript": "^3.7.5" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/model.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | import { CookieJar } from "request"; 4 | 5 | export * from "./model/account"; 6 | export * from "./model/budget"; 7 | export * from "./model/category"; 8 | export * from "./model/provider"; 9 | export * from "./model/tag"; 10 | export * from "./model/transaction"; 11 | 12 | export interface ICredentialExtras { 13 | token?: string; 14 | cookies?: string | any[]; 15 | } 16 | 17 | export interface IMintCredentials { 18 | email: string; 19 | password: string; 20 | extras?: ICredentialExtras; 21 | } 22 | 23 | export interface ICookie { 24 | name: string; 25 | value: string; 26 | } 27 | 28 | export interface IMintAuth { 29 | cookies: ICookie[]; 30 | token: string; 31 | } 32 | 33 | export interface IMintAuthorizer { 34 | authorize( 35 | events: EventEmitter, 36 | credentials: IMintCredentials, 37 | ): Promise; 38 | } 39 | 40 | export interface IJsonForm { 41 | args?: {[key: string]: any}; 42 | service: string; 43 | task: string; 44 | } 45 | 46 | export interface INetService { 47 | load(url: string): Promise; 48 | getJson( 49 | url: string, 50 | qs?: {[key: string]: string | number}, 51 | headers?: {[key: string]: string}, 52 | ): Promise; 53 | jsonForm(form: IJsonForm): Promise; 54 | postForm( 55 | url: string, 56 | form: {[key: string]: string | number}, 57 | headers?: {[key: string]: string}, 58 | ): Promise; 59 | postJson( 60 | url: string, 61 | json: any, 62 | headers?: {[key: string]: string}, 63 | ): Promise; 64 | 65 | setAuth(auth: IMintAuth): void; 66 | getCookies(): CookieJar; 67 | setCookie(name: string, value: string): void; 68 | } 69 | -------------------------------------------------------------------------------- /src/model/transaction.ts: -------------------------------------------------------------------------------- 1 | export interface IMintTransactionQuery { 2 | accountId?: number; 3 | category?: number | { id: number }; 4 | offset?: number; 5 | query?: string | string[]; 6 | 7 | startDate?: Date; 8 | endDate?: Date; 9 | } 10 | 11 | export interface IMintTransaction { 12 | /** ex: 'Feb 11' */ 13 | date: string; 14 | note: string; 15 | isPercent: boolean; 16 | fi: string; 17 | txnType: number; 18 | /** or -1 */ 19 | numberMatchedByRule: number; 20 | isEdited: boolean; 21 | isPending: boolean; 22 | mcategory: string; 23 | isMatched: boolean; 24 | /** ex: 'Feb 11' */ 25 | odate: string; 26 | isFirstDate: boolean; 27 | id: number; 28 | isDuplicate: boolean; 29 | hasAttachments: boolean; 30 | isChild: boolean; 31 | isSpending: boolean; 32 | /** ex: '$45.00' */ 33 | amount: string; 34 | ruleCategory: string; 35 | userCategoryId: number | null; 36 | isTransfer: boolean; 37 | isAfterFiCreationTime: boolean; 38 | merchant: string; 39 | manualType: number; 40 | labels: []; 41 | mmerchant: string; 42 | isCheck: boolean; 43 | /** "original merchant name" */ 44 | omerchant: string; 45 | isDebit: boolean; 46 | category: string; 47 | ruleMerchant: string; 48 | isLinkedToRule: boolean; 49 | account: string; 50 | categoryId: number; 51 | ruleCategoryId: number; 52 | } 53 | 54 | export interface INewTransaction { 55 | /** Apparently ignored, but good to have, I guess? */ 56 | accountId?: number; 57 | amount: 4.2; 58 | 59 | /** If not provided, the txn will show up as UNCATEGORIZED */ 60 | category?: { 61 | id: number; 62 | name: string; 63 | }; 64 | 65 | /** If a string, use format: "MM/DD/YYYY" */ 66 | date: string | Date; 67 | 68 | isExpense: boolean; 69 | isInvestment: boolean; 70 | 71 | /** Merchant name */ 72 | merchant: string; 73 | note?: string; 74 | 75 | /** set of IDs */ 76 | tags?: number[]; 77 | } 78 | 79 | export interface ITransactionEdit { 80 | id: number; 81 | 82 | /** EX: 'Bills & Utilities' */ 83 | category: string; 84 | categoryId: number; 85 | date: string | Date; 86 | merchant: string; 87 | } 88 | -------------------------------------------------------------------------------- /test/util/budget-test.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | 3 | import { formatBudgetQuery } from "../../src/util/budget"; 4 | import { IClock } from "../../src/util/clock"; 5 | 6 | chai.should(); 7 | 8 | class FixedClock implements IClock { 9 | constructor( 10 | private readonly date: Date, 11 | ) {} 12 | 13 | public now() { 14 | return this.date; 15 | } 16 | } 17 | 18 | const clock = new FixedClock( 19 | new Date(2017, 7, 27), // aug 27 20 | ); 21 | 22 | describe('pepper-mint', function() { 23 | describe('getBudgets arg parsing', function() { 24 | 25 | it('handles default "this month"', function() { 26 | var args = formatBudgetQuery(clock, clock.now()); 27 | 28 | args.startDate.should.equal("8/1/2017"); 29 | args.endDate.should.equal("9/1/2017"); 30 | }); 31 | 32 | it('handles a single month', function() { 33 | var args = formatBudgetQuery(clock, new Date(2017, 4, 22)); 34 | 35 | args.startDate.should.equal("5/1/2017"); 36 | args.endDate.should.equal("6/1/2017"); 37 | }); 38 | 39 | it('handles simple months option', function() { 40 | var args = formatBudgetQuery(clock, { 41 | months: 1 42 | }); 43 | 44 | args.startDate.should.equal("8/1/2017"); 45 | args.endDate.should.equal("9/1/2017"); 46 | }); 47 | 48 | it('errors on invalid months option', function() { 49 | (function() { 50 | formatBudgetQuery(clock, {months: 0}); 51 | }).should.throw("Invalid `months`"); 52 | }); 53 | 54 | it('handles months option crossing year boundary *back*', function() { 55 | var args = formatBudgetQuery(clock, { 56 | months: 9 57 | }); 58 | 59 | args.startDate.should.equal("12/1/2016"); 60 | args.endDate.should.equal("9/1/2017"); 61 | }); 62 | 63 | it('handles months option crossing year boundary *forward*', function() { 64 | const clock1 = new FixedClock(new Date(2017, 11, 27)); // dec 27 65 | var args = formatBudgetQuery(clock1, { 66 | months: 2 67 | }); 68 | 69 | args.startDate.should.equal("11/1/2017"); 70 | args.endDate.should.equal("1/1/2018"); 71 | 72 | const clock2 = new FixedClock(new Date(2018, 0, 7)); // jan 27 73 | var args = formatBudgetQuery(clock2, { 74 | months: 2 75 | }); 76 | 77 | args.startDate.should.equal("12/1/2017"); 78 | args.endDate.should.equal("2/1/2018"); 79 | }); 80 | }); 81 | }); 82 | 83 | -------------------------------------------------------------------------------- /src/auth/legacy-auth.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | import { IMintAuthorizer, IMintCredentials, INetService } from "../model"; 4 | 5 | import { extractSessionCookies } from "./session-cookies"; 6 | 7 | const URL_BASE_ACCOUNTS = "https://accounts.intuit.com/access_client/"; 8 | const URL_SESSION_INIT = "https://pf.intuit.com/fp/tags?js=0&org_id=v60nf4oj&session_id="; 9 | 10 | const BROWSER = "chrome"; 11 | const BROWSER_VERSION = 58; 12 | const OS_NAME = "mac"; 13 | 14 | export class LegacyMintAuth implements IMintAuthorizer { 15 | 16 | constructor( 17 | private readonly net: INetService, 18 | ) {} 19 | 20 | public async authorize( 21 | events: EventEmitter, 22 | credentials: IMintCredentials, 23 | ) { 24 | const session = extractSessionCookies(credentials); 25 | if (!session || !session.cookies) { 26 | throw new Error("No session cookies"); 27 | } 28 | 29 | const cookiesMap = session.cookies.reduce( 30 | (m, cookie) => { 31 | m[cookie.name] = cookie.value; 32 | return m; 33 | }, 34 | {} 35 | ); 36 | if (!cookiesMap.ius_session) { 37 | throw new Error("No session cookies"); 38 | } 39 | 40 | // initialize the session 41 | session.cookies.forEach(cookie => { 42 | this.net.setCookie(cookie.name, cookie.value); 43 | }); 44 | 45 | await this.net.load(URL_SESSION_INIT + cookiesMap.ius_session); 46 | 47 | const auth = await this.net.postForm(URL_BASE_ACCOUNTS + "sign_in", { 48 | username: credentials.email, 49 | password: credentials.password, 50 | }); 51 | 52 | // save the pod number (or whatever) in a cookie 53 | const json = await this.net.postForm("getUserPod.xevent", { 54 | clientType: 'Mint', 55 | authid: auth.iamTicket.userId, 56 | }); 57 | this.net.setCookie("mintPN", json.mintPN); 58 | 59 | // finally, login 60 | const tokenJson = await this.net.postForm('loginUserSubmit.xevent', { 61 | task: 'L', 62 | browser: BROWSER, 63 | browserVersion: BROWSER_VERSION, 64 | os: OS_NAME, 65 | }); 66 | 67 | if (tokenJson.error && tokenJson.error.vError) { 68 | throw new Error(tokenJson.error.vError.copy); 69 | } 70 | if (!(tokenJson.sUser && tokenJson.sUser.token)) { 71 | throw new Error("Unable to obtain token"); 72 | } 73 | 74 | return { 75 | cookies: session.cookies, 76 | token: tokenJson.sUser.token, 77 | }; 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /src/util/budget.ts: -------------------------------------------------------------------------------- 1 | import { PepperMint } from "../core"; 2 | import { IBudgetLastNMonthsQuery, IBudgetQuery, IMintBudget } from "../model/budget"; 3 | import { IMintCategory } from "../model/category"; 4 | 5 | import { IClock } from "./clock"; 6 | import { firstDayOfNextMonth } from "./date"; 7 | 8 | export function isLastNMonthsQuery(query: IBudgetQuery): query is IBudgetLastNMonthsQuery { 9 | const { months } = query as any; 10 | return typeof months === "number"; 11 | } 12 | 13 | function formatBudgetQueryDate(d: Date) { 14 | return (d.getMonth() + 1) 15 | + "/" + d.getDate() 16 | + "/" + d.getFullYear(); 17 | } 18 | 19 | export function formatBudgetQuery( 20 | clock: IClock, 21 | query: IBudgetQuery | Date, 22 | ) { 23 | let start: Date; 24 | let end: Date; 25 | 26 | if (query instanceof Date) { 27 | start = new Date(query.getFullYear(), query.getMonth()); 28 | end = firstDayOfNextMonth(query); 29 | } else if (isLastNMonthsQuery(query)) { 30 | if (query.months <= 0) { 31 | throw new Error("Invalid `months` argument: " + query.months); 32 | } 33 | 34 | const now = clock.now(); 35 | end = firstDayOfNextMonth(now); 36 | 37 | // there may be a way to do this without a loop, 38 | // but this is simple and understandable, and even if 39 | // someone requests 100 years of data, this won't take too long. 40 | let startYear = end.getFullYear(); 41 | let startMonth = end.getMonth() - query.months; 42 | while (startMonth < 0) { 43 | --startYear; 44 | startMonth += 12; 45 | } 46 | 47 | start = new Date(startYear, startMonth); 48 | } else { 49 | start = query.start; 50 | end = query.end; 51 | } 52 | 53 | return { 54 | startDate: formatBudgetQueryDate(start), 55 | endDate: formatBudgetQueryDate(end), 56 | rnd: clock.now().getTime(), 57 | }; 58 | } 59 | 60 | export function budgetForKey( 61 | mint: PepperMint, 62 | categories: IMintCategory[], 63 | data: any, 64 | budgetKey: string, 65 | ): IMintBudget { 66 | const income = data.income[budgetKey]; 67 | const spending = data.spending[budgetKey]; 68 | 69 | for (const budgetSet of [ income.bu, spending.bu, income.ub, spending.ub ]) { 70 | for (const budget of budgetSet) { 71 | budget.category = mint.getCategoryNameById( 72 | categories, 73 | budget.cat 74 | ); 75 | } 76 | } 77 | 78 | return { 79 | income: income.bu, 80 | spending: spending.bu, 81 | unbudgeted: { 82 | income: income.ub, 83 | spending: spending.ub, 84 | }, 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /src/auth/chromedriver-auth.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | import webdriver, { By, until } from "selenium-webdriver"; 4 | 5 | import { IMintAuthorizer, IMintCredentials } from "../model"; 6 | 7 | const URL_BASE = "https://mint.intuit.com/"; 8 | const URL_LOGIN = URL_BASE + "login.event"; 9 | 10 | export class ChromedriverMintAuth implements IMintAuthorizer { 11 | 12 | public async authorize( 13 | events: EventEmitter, 14 | credentials: IMintCredentials, 15 | ) { 16 | require("chromedriver"); 17 | 18 | const driver = await this.createDriver(); 19 | try { 20 | return await this.authWithDriver(events, driver, credentials); 21 | } finally { 22 | await driver.close(); 23 | events.emit("browser-login", "done"); 24 | } 25 | } 26 | 27 | private async authWithDriver( 28 | events: EventEmitter, 29 | driver: webdriver.WebDriver, 30 | credentials: IMintCredentials, 31 | ) { 32 | events.emit('browser-login', 'init'); 33 | 34 | await driver.get(URL_LOGIN); 35 | await driver.wait(until.elementLocated(By.id("ius-sign-in-submit-btn"))); 36 | await driver.findElement(By.id("ius-userid")).sendKeys(credentials.email); 37 | await driver.findElement(By.id("ius-password")).sendKeys(credentials.password); 38 | await driver.findElement(By.id("ius-sign-in-submit-btn")).submit(); 39 | 40 | // we will probably need 2fa... wait until actually logged in 41 | await driver.wait(until.urlIs(URL_BASE + "overview.event")); 42 | events.emit('browser-login', 'login'); 43 | 44 | const el = await driver.wait(elementAttrMatches(By.id("javascript-user"), 'value', v => { 45 | return v && v.length > 0 && v !== '{}'; 46 | })); 47 | 48 | events.emit('browser-login', 'RESOLVE!'); 49 | const jsonString = await el.getAttribute("value"); 50 | const json = JSON.parse(jsonString); 51 | 52 | events.emit('browser-login', 'cookie'); 53 | if (!(json && json.token)) { 54 | throw new Error("No user token: " + json); 55 | } 56 | 57 | const cookies = await driver.manage().getCookies(); 58 | return { 59 | token: json.token, 60 | cookies, 61 | }; 62 | } 63 | 64 | private async createDriver() { 65 | const driver = await new webdriver.Builder() 66 | .forBrowser('chrome') 67 | .build(); 68 | 69 | if (!driver) { 70 | throw new Error( 71 | "token not provided, and unable to " + 72 | "load chromedriver + selenium" 73 | ); 74 | } 75 | 76 | return driver; 77 | } 78 | } 79 | 80 | function elementAttrMatches( 81 | locator: webdriver.Locator, 82 | attrName: string, 83 | predicate: (attr: string) => any | undefined, 84 | ) { 85 | return new webdriver.WebElementCondition( 86 | 'until element[' + attrName + '] matches', 87 | (async (driver: webdriver.WebDriver) => { 88 | const els = await driver.findElements(locator); 89 | if (!els.length) return; 90 | 91 | const el = els[0]; 92 | const attr = await el.getAttribute(attrName); 93 | if (predicate(attr)) { 94 | return el; 95 | } 96 | }) as any, 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | import { MintAuth } from "./auth"; 4 | import { PepperMint } from "./core"; 5 | import { DeferredAuth } from "./deferred"; 6 | import { IMintCredentials, INetService } from "./model"; 7 | import * as ModelTypes from "./model"; 8 | import { RequestNetService } from "./net"; 9 | 10 | interface IPepperMintPromise extends Promise { 11 | mint: EventEmitter; 12 | } 13 | 14 | function prepare( 15 | email: string, 16 | password: string, 17 | token?: any, 18 | cookies?: any, 19 | ): IPepperMintPromise { 20 | const net = new RequestNetService(); 21 | const auth = new MintAuth(net); 22 | const deferredAuth = new DeferredAuth(); 23 | const mint = new PepperMint(net, deferredAuth); 24 | const promise = authorize(mint, deferredAuth, net, auth, { 25 | email, 26 | password, 27 | extras: { 28 | token, 29 | cookies, 30 | }, 31 | }) as IPepperMintPromise; 32 | promise.mint = mint; 33 | return promise; 34 | } 35 | 36 | async function authorize( 37 | mint: PepperMint, 38 | deferredAuth: DeferredAuth, 39 | net: INetService, 40 | auth: MintAuth, 41 | creds: IMintCredentials, 42 | ) { 43 | const authData = await auth.authorize(mint, creds); 44 | deferredAuth.resolve(authData); 45 | return mint; 46 | } 47 | 48 | export = prepare; 49 | 50 | /* eslint-disable */ 51 | namespace prepare { 52 | // manually re-export model types in a namespace matching the default export 53 | // for convenient consumption from typescript, since we sadly can't use the 54 | // `export * from` syntax within the namespace 55 | 56 | export type ICredentialExtras = ModelTypes.ICredentialExtras; 57 | export type IMintCredentials = ModelTypes.IMintCredentials; 58 | export type ICookie = ModelTypes.ICookie; 59 | export type IMintAuth = ModelTypes.IMintAuth; 60 | export type IMintAuthorizer = ModelTypes.IMintAuthorizer; 61 | export type IJsonForm = ModelTypes.IJsonForm; 62 | export type INetService = ModelTypes.INetService; 63 | 64 | export type IMintAccount = ModelTypes.IMintAccount; 65 | 66 | export type IBudgetRangeQuery = ModelTypes.IBudgetRangeQuery; 67 | export type IBudgetLastNMonthsQuery = ModelTypes.IBudgetLastNMonthsQuery; 68 | export type IBudgetQuery = ModelTypes.IBudgetQuery; 69 | export type IMintBudgetItem = ModelTypes.IMintBudgetItem; 70 | export type IMintBudgetData = ModelTypes.IMintBudgetData; 71 | export type IMintBudget = ModelTypes.IMintBudget; 72 | export type IMintCategory = ModelTypes.IMintCategory; 73 | 74 | export type IMintProviderMetadata = ModelTypes.IMintProviderMetadata; 75 | export type IMintProviderAccountBase = ModelTypes.IMintProviderAccountBase; 76 | export type IMintProviderBankAccount = ModelTypes.IMintProviderBankAccount; 77 | export type IMintProviderCreditAccount = ModelTypes.IMintProviderCreditAccount; 78 | export type IMintProviderInvestmentAccount = ModelTypes.IMintProviderInvestmentAccount; 79 | export type IMintProviderLoanAccount = ModelTypes.IMintProviderLoanAccount; 80 | export type IMintProviderAccount = ModelTypes.IMintProviderAccount; 81 | export type IMintProvider = ModelTypes.IMintProvider; 82 | 83 | export type IMintTag = ModelTypes.IMintTag; 84 | 85 | export type IMintTransactionQuery = ModelTypes.IMintTransactionQuery; 86 | export type IMintTransaction = ModelTypes.IMintTransaction; 87 | export type INewTransaction = ModelTypes.INewTransaction; 88 | export type ITransactionEdit = ModelTypes.ITransactionEdit; 89 | } 90 | -------------------------------------------------------------------------------- /release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Release script for pepper-mint 4 | # 5 | 6 | import datetime 7 | from collections import OrderedDict 8 | 9 | try: 10 | from hostage import * 11 | except ImportError: 12 | print("!! Release library unavailable.") 13 | print("!! Use `pip install hostage` to fix.") 14 | print("!! You will also need an API token in .github.token,") 15 | print("!! a .hubrrc config, or `brew install hub` configured.") 16 | print("!! A $GITHUB_TOKEN env variable will also work.") 17 | exit(1) 18 | 19 | # 20 | # Globals 21 | # 22 | 23 | notes = File(".last-release-notes") 24 | latestTag = git.Tag.latest() 25 | 26 | def formatIssue(issue): 27 | return "- {title} (#{number})\n".format( 28 | number=issue.number, 29 | title=issue.title) 30 | 31 | def buildLabeled(labelsToTitles): 32 | """Given a set of (label, title) tuples, produces an 33 | OrderedDict whose keys are `label`, and whose values are 34 | dictionaries containing 'title' -> `title`, and 35 | 'content' -> string. The iteration order of the dictionary 36 | will preserve the ordering of the provided tuples 37 | """ 38 | result = OrderedDict() 39 | for k, v in labelsToTitles: 40 | result[k] = {'title': v, 'content': ''} 41 | return result 42 | 43 | def buildDefaultNotes(_): 44 | if not latestTag: return '' 45 | 46 | logParams = { 47 | 'path': latestTag.name + "..HEAD", 48 | 'grep': ["Fix #", "Fixes #", "Closes #"], 49 | 'pretty': "format:- %s"} 50 | logParams["invertGrep"] = True 51 | msgs = git.Log(**logParams).output() 52 | 53 | contents = '' 54 | 55 | lastReleaseDate = latestTag.get_created_date() 56 | if lastReleaseDate.tzinfo: 57 | # pygithub doesn't respect tzinfo, so we have to do it ourselves 58 | lastReleaseDate -= lastReleaseDate.tzinfo.utcoffset(lastReleaseDate) 59 | lastReleaseDate.replace(tzinfo=None) 60 | 61 | closedIssues = github.find_issues(state='closed', since=lastReleaseDate) 62 | 63 | labeled = buildLabeled([ 64 | ['feature', "New Features"], 65 | ['enhancement', "Enhancements"], 66 | ['bug', "Bug Fixes"], 67 | ['_default', "Other resolved tickets"], 68 | ]) 69 | 70 | if closedIssues: 71 | for issue in closedIssues: 72 | found = False 73 | for label in labeled.keys(): 74 | if label in issue.labels: 75 | labeled[label]['content'] += formatIssue(issue) 76 | found = True 77 | break 78 | if not found: 79 | labeled['_default']['content'] += formatIssue(issue) 80 | 81 | for labeledIssueInfo in labeled.values(): 82 | if labeledIssueInfo['content']: 83 | contents += "\n**{title}**:\n{content}".format(**labeledIssueInfo) 84 | 85 | if msgs: contents += "\n**Notes**:\n" + msgs 86 | return contents.strip() 87 | 88 | # 89 | # Verify 90 | # 91 | 92 | version = verify(File("package.json") 93 | .filtersTo(RegexFilter('"version": "(.*)"')) 94 | ).valueElse(echoAndDie("No version!?")) 95 | versionTag = git.Tag(version) 96 | 97 | verify(versionTag.exists())\ 98 | .then(echoAndDie("Version `%s` already exists!" % version)) 99 | 100 | # 101 | # Make sure all the tests pass 102 | # 103 | 104 | verify(Execute("npm test")).succeeds(silent=False).orElse(die()) 105 | 106 | # 107 | # Build the release notes 108 | # 109 | 110 | contents = verify(notes.contents()).valueElse(buildDefaultNotes) 111 | notes.delete() 112 | 113 | verify(Edit(notes, withContent=contents).didCreate())\ 114 | .orElse(echoAndDie("Aborted due to empty message")) 115 | 116 | releaseNotes = notes.contents() 117 | 118 | # 119 | # Deploy 120 | # 121 | 122 | verify(Execute('npm publish')).succeeds(silent=False) 123 | 124 | # 125 | # Upload to github 126 | # 127 | 128 | print("Uploading to Github...") 129 | 130 | verify(versionTag).create() 131 | verify(versionTag).push("origin") 132 | 133 | gitRelease = github.Release(version) 134 | verify(gitRelease).create(body=releaseNotes) 135 | 136 | # 137 | # Success! Now, just cleanup and we're done! 138 | # 139 | 140 | notes.delete() 141 | 142 | print("Done! Published %s" % version) 143 | 144 | # flake8: noqa 145 | -------------------------------------------------------------------------------- /src/net.ts: -------------------------------------------------------------------------------- 1 | import { CookieJar } from "request"; 2 | import request from "request-promise-native"; 3 | 4 | import { IJsonForm, IMintAuth, INetService } from "./model"; 5 | 6 | export const URL_BASE = "https://mint.intuit.com/"; 7 | export const URL_BASE_ACCOUNTS = "https://accounts.intuit.com/access_client/"; 8 | export const URL_SESSION_INIT = "https://pf.intuit.com/fp/tags?js=0&org_id=v60nf4oj&session_id="; 9 | 10 | const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"; 11 | 12 | function checkJsonResponse(response: any) { 13 | if (typeof response === "string") { 14 | if (response.includes("Session has expired.")) { 15 | throw new Error("Session has expired"); 16 | } 17 | if (response.includes("")) { 18 | return { success: true }; 19 | } 20 | } 21 | } 22 | 23 | export class RequestNetService implements INetService { 24 | 25 | private readonly jar = request.jar(); 26 | private readonly request = request.defaults({ jar: this.jar }); 27 | 28 | private token: string | undefined; 29 | private requestId = 42; // magic number? random number? 30 | 31 | public load(url: string): Promise { 32 | return this.request.get(resolveUrl(url)); 33 | } 34 | 35 | public async getJson( 36 | url: string, 37 | qs?: {[key: string]: string}, 38 | headers?: {[key: string]: string}, 39 | ) { 40 | const response = await this.request.get({ 41 | url: resolveUrl(url), 42 | json: true, 43 | headers, 44 | qs, 45 | }); 46 | checkJsonResponse(response); 47 | return response; 48 | } 49 | 50 | public async jsonForm( 51 | form: IJsonForm, 52 | ) { 53 | const reqId = '' + this.requestId++; 54 | (form as any).id = reqId; 55 | const url = "bundledServiceController.xevent?legacy=false&token=" + this.getToken(); 56 | 57 | const resp = await this.postForm(url, { 58 | input: JSON.stringify([form]), // weird 59 | }); 60 | 61 | if (!resp.response) { 62 | const task = form.service + "/" + form.task; 63 | throw new Error("Unable to parse response for " + task); 64 | } 65 | 66 | return resp.response[reqId].response; 67 | } 68 | 69 | public async postForm( 70 | url: string, 71 | form: { [key: string]: string | number }, 72 | headers?: { [key: string]: string }, 73 | ): Promise { 74 | const fullHeaders = { 75 | "Accept": "application/json", 76 | "User-Agent": USER_AGENT, 77 | "X-Request-With": 'XMLHttpRequest', 78 | "X-NewRelic-ID": 'UA4OVVFWGwYJV1FTBAE=', 79 | "Referrer": 'https://mint.intuit.com/login.event?task=L&messageId=5&country=US', 80 | 81 | ...headers, 82 | }; 83 | const result = await this.request.post({ 84 | url: resolveUrl(url), 85 | json: true, 86 | form, 87 | headers: fullHeaders, 88 | }); 89 | checkJsonResponse(result); 90 | return result; 91 | } 92 | 93 | public async postJson( 94 | url: string, 95 | json: any, 96 | headers?: { [key: string]: string }, 97 | ): Promise { 98 | const result = await this.request.post({ 99 | url: resolveUrl(url), 100 | json, 101 | headers, 102 | }); 103 | checkJsonResponse(result); 104 | return result; 105 | } 106 | 107 | public getCookies(): CookieJar { 108 | return this.jar; 109 | } 110 | 111 | public setAuth(auth: IMintAuth) { 112 | this.token = auth.token; 113 | for (const cookie of auth.cookies) { 114 | this.setCookie(cookie.name, cookie.value); 115 | } 116 | } 117 | 118 | public setCookie(name: string, value: string) { 119 | const cookie = `${name}=${value}`; 120 | this.jar.setCookie(cookie, URL_BASE); 121 | this.jar.setCookie(cookie, URL_BASE_ACCOUNTS); 122 | this.jar.setCookie(cookie, URL_SESSION_INIT); 123 | } 124 | 125 | private getToken(): string { 126 | const token = this.token; 127 | if (!token) throw new Error("No token"); 128 | return token; 129 | } 130 | } 131 | 132 | function resolveUrl(input: string) { 133 | if (input.startsWith("http")) { 134 | return input; 135 | } 136 | 137 | if (input.startsWith("/")) { 138 | return URL_BASE + input.substring(1); 139 | } 140 | 141 | return URL_BASE + input; 142 | } 143 | -------------------------------------------------------------------------------- /src/model/provider.ts: -------------------------------------------------------------------------------- 1 | /** ex: '2019-10-29T20:57:26Z' */ 2 | type IProviderDate = string; 3 | 4 | export interface IMintProviderMetadata { 5 | createdDate: IProviderDate; 6 | lastUpdatedDate: IProviderDate; 7 | link: any[]; 8 | } 9 | 10 | export interface IMintProviderAccountBase { 11 | metaData: IMintProviderMetadata; 12 | id: string; 13 | domain: string; 14 | domainIds: { domain: string; id: string }[]; 15 | name: string; 16 | cpId: string; 17 | accountStatus: "ACTIVE" | "CLOSED"; 18 | accountNumberLast4: string; 19 | currentBalance: number; 20 | value: number; 21 | isVisible: boolean; 22 | isDeleted: boolean; 23 | planningTrendsVisible: boolean; 24 | /** EX: 'USD' */ 25 | currency: string; 26 | accountTypeInt: number; 27 | isAccountClosedByMint: boolean; 28 | isAccountNotFound: boolean; 29 | isActive: boolean; 30 | hostAccount: boolean; 31 | isClosed: boolean; 32 | isError: boolean; 33 | isHiddenFromPlanningTrends: boolean; 34 | isTerminal: boolean; 35 | autoPay: boolean; 36 | isBillVisible: boolean; 37 | isPaymentMethodVisible: boolean; 38 | isEmailNotificationEnabled: boolean; 39 | isPushNotificationEnabled: boolean; 40 | systemStatus: "ACTIVE"; 41 | accountStatusCode: string; 42 | } 43 | 44 | export interface IMintProviderBankAccount extends IMintProviderAccountBase { 45 | type: "BankAccount"; 46 | bankAccountType: "CHECKING" | "SAVINGS"; 47 | availableBalance: number; 48 | interestRate: number; 49 | cpInterestRate: number; 50 | minimumNoFeeBalance: number; 51 | userMinimumNoFeeBalance: number; 52 | monthlyFee: number; 53 | userMonthlyFee: number; 54 | userFreeBillPay: boolean; 55 | userAtmFeeReimbursement: boolean; 56 | numOfTransactions: number; 57 | } 58 | 59 | export interface IMintProviderCreditAccount extends IMintProviderAccountBase { 60 | type: "CreditAccount"; 61 | userCardType: "UNKNOWN" | "VISA"; 62 | creditAccountType: "CREDIT_CARD" | "UNKNOWN"; 63 | creditLimit: number; 64 | availableCredit: number; 65 | interestRate: number; 66 | userRewardsType: "MILES" | "POINTS"; 67 | rewardsRate: number; 68 | annualFee: number; 69 | minPayment: number; 70 | absoluteMinPayment: number; 71 | statementMinPayment: number; 72 | statementDueAmount: number; 73 | } 74 | 75 | export interface IMintProviderInvestmentAccount extends IMintProviderAccountBase { 76 | type: "InvestmentAccount"; 77 | investmentType: "TAXABLE"; 78 | dormant401K: boolean; 79 | } 80 | 81 | export interface IMintProviderLoanAccount extends IMintProviderAccountBase { 82 | type: "LoanAccount"; 83 | loanType: "LOAN"; 84 | loanTermType: "OTHER"; 85 | loanInterestRateType: "OTHER"; 86 | amountDue: number; 87 | originalLoanAmount: number; 88 | principalBalance: number; 89 | interestRate: number; 90 | minPayment: number; 91 | absoluteMinPayment: number; 92 | statementMinPayment: number; 93 | statementDueAmount: number; 94 | statementDueDate: IProviderDate; 95 | } 96 | 97 | export type IMintProviderAccount = 98 | IMintProviderBankAccount 99 | | IMintProviderCreditAccount 100 | | IMintProviderInvestmentAccount 101 | | IMintProviderLoanAccount; 102 | 103 | export interface IMintProvider { 104 | metaData: IMintProviderMetadata; 105 | id: string; 106 | cpProviderId: string; 107 | domainIds: []; 108 | name: string; 109 | type: "FINANCIAL"; 110 | lastSuccessfulRefreshTime: IProviderDate; 111 | /** ex: '1 day' */ 112 | lastUpdatedInString: string; 113 | providerStatus: { 114 | status: 'OK' | "FAILED_NEW_MFA_CHALLENGE_REQUIRED" | "FAILED_UNKNOWN_CAUSE"; 115 | statusCode: number; 116 | statusIsTerminal: boolean; 117 | lastStatusUpdateTime: IProviderDate; 118 | }; 119 | secondaryRunningStatus: 'NOT_RUNNING'; 120 | contentProvider: { 121 | /** ex: 'INTUIT_FDS' */ 122 | name: string; 123 | status: string; 124 | statusMessage: string; 125 | }; 126 | providerAccounts: IMintProviderAccount[]; 127 | staticProviderRef: { 128 | id: string; 129 | legacyId: string; 130 | name: string; 131 | mfaEnabled: boolean; 132 | mfaType: "IMAGE" | "NON_MFA" | "UNKNOWN"; 133 | supportsLinkedBills: boolean; 134 | durableDataEnabled: boolean; 135 | recaptchaRequired: boolean; 136 | accountEntitlementEnabled: boolean; 137 | logos: []; 138 | channels: []; 139 | supplementalMessage: ''; 140 | supplementalMessageUrl: ''; 141 | helpInfo: any; 142 | contacts: any; 143 | websites: any; 144 | providerCategories: any; 145 | }; 146 | durableDataEnabled: boolean; 147 | recaptchaRequired: boolean; 148 | fdpEnabled: boolean; 149 | } 150 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "project": "tsconfig.json", 14 | "sourceType": "module" 15 | }, 16 | "plugins": [ 17 | "@typescript-eslint", 18 | "@typescript-eslint/tslint", 19 | "eslint-plugin-import", 20 | "eslint-plugin-prefer-arrow" 21 | ], 22 | "rules": { 23 | "@typescript-eslint/adjacent-overload-signatures": "error", 24 | "@typescript-eslint/array-type": "error", 25 | "@typescript-eslint/ban-types": "error", 26 | "@typescript-eslint/class-name-casing": "error", 27 | "@typescript-eslint/consistent-type-assertions": "error", 28 | "@typescript-eslint/explicit-function-return-type": "off", 29 | "@typescript-eslint/interface-name-prefix": [ 30 | "error", 31 | { 32 | "prefixWithI": "always" 33 | } 34 | ], 35 | "@typescript-eslint/member-delimiter-style": [ 36 | "error", 37 | { 38 | "multiline": { 39 | "delimiter": "semi", 40 | "requireLast": true 41 | }, 42 | "singleline": { 43 | "delimiter": "semi", 44 | "requireLast": false 45 | } 46 | } 47 | ], 48 | "@typescript-eslint/no-empty-function": "error", 49 | "@typescript-eslint/no-empty-interface": "error", 50 | "@typescript-eslint/no-explicit-any": "off", 51 | "@typescript-eslint/no-misused-new": "error", 52 | "@typescript-eslint/no-namespace": "error", 53 | "@typescript-eslint/no-parameter-properties": "off", 54 | "@typescript-eslint/no-use-before-define": "off", 55 | "@typescript-eslint/no-var-requires": "error", 56 | "@typescript-eslint/prefer-for-of": "error", 57 | "@typescript-eslint/prefer-function-type": "error", 58 | "@typescript-eslint/prefer-namespace-keyword": "error", 59 | "@typescript-eslint/semi": [ 60 | "error" 61 | ], 62 | "@typescript-eslint/triple-slash-reference": "error", 63 | "@typescript-eslint/unified-signatures": "error", 64 | "arrow-parens": [ 65 | "error", 66 | "as-needed" 67 | ], 68 | "camelcase": "error", 69 | "complexity": "off", 70 | "constructor-super": "error", 71 | "curly": [ 72 | "error", 73 | "multi-line" 74 | ], 75 | "dot-notation": "error", 76 | "eqeqeq": [ 77 | "error", 78 | "smart" 79 | ], 80 | "guard-for-in": "error", 81 | "id-blacklist": [ 82 | "error", 83 | "any", 84 | "Number", 85 | "number", 86 | "String", 87 | "string", 88 | "Boolean", 89 | "boolean", 90 | "Undefined", 91 | "undefined" 92 | ], 93 | "id-match": "error", 94 | "import/order": [ 95 | "error", 96 | { 97 | "alphabetize": { 98 | "order": "asc" 99 | }, 100 | "newlines-between": "always" 101 | } 102 | ], 103 | "max-classes-per-file": "off", 104 | "new-parens": "error", 105 | "no-bitwise": "error", 106 | "no-caller": "error", 107 | "no-cond-assign": "error", 108 | "no-console": "error", 109 | "no-debugger": "error", 110 | "no-empty": "error", 111 | "no-eval": "error", 112 | "no-fallthrough": "off", 113 | "no-invalid-this": "off", 114 | "no-new-wrappers": "error", 115 | "no-shadow": [ 116 | "error", 117 | { 118 | "hoist": "all" 119 | } 120 | ], 121 | "no-throw-literal": "error", 122 | "no-trailing-spaces": "error", 123 | "no-undef-init": "error", 124 | "no-underscore-dangle": "error", 125 | "no-unsafe-finally": "error", 126 | "no-unused-expressions": "error", 127 | "no-unused-labels": "error", 128 | "no-var": "error", 129 | "object-shorthand": "error", 130 | "one-var": [ 131 | "error", 132 | "never" 133 | ], 134 | "prefer-arrow-callback": "error", 135 | "prefer-const": "error", 136 | "radix": "error", 137 | "semi": "off", 138 | "sort-imports": [ 139 | "error", 140 | { 141 | "ignoreDeclarationSort": true 142 | }, 143 | ], 144 | "spaced-comment": "error", 145 | "use-isnan": "error", 146 | "valid-typeof": "off", 147 | "@typescript-eslint/tslint/config": [ 148 | "error", 149 | { 150 | "rules": { 151 | "jsdoc-format": true, 152 | "no-reference-import": true 153 | } 154 | } 155 | ] 156 | } 157 | }; 158 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2016", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "dist", /* Redirect output structure to the directory. */ 15 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": true, /* Enable all strict type-checking options. */ 25 | "stripInternal": true, 26 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 27 | // "strictNullChecks": true, /* Enable strict null checks. */ 28 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 29 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 30 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 31 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 32 | 33 | /* Additional Checks */ 34 | "noUnusedLocals": true, /* Report errors on unused locals. */ 35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 37 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 38 | 39 | /* Module Resolution Options */ 40 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 41 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 44 | // "typeRoots": [], /* List of folders to include type definitions from. */ 45 | // "types": [], /* Type declaration files to be included in compilation. */ 46 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 47 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 48 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 49 | 50 | /* Source Map Options */ 51 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 52 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 53 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 54 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 55 | 56 | /* Experimental Options */ 57 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 58 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 59 | 60 | "typeRoots": [ 61 | "./node_modules/@types", 62 | "./src/@types" 63 | ] 64 | }, 65 | "include": [ 66 | "src/**/*" 67 | ] 68 | } 69 | 70 | 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pepper-mint 2 | =========== 3 | 4 | An unofficial, promise-based [mint.com](https://www.mint.com) API in node.js. 5 | Builds on the [work by mroony](https://github.com/mrooney/mintapi) 6 | 7 | 8 | ### Usage 9 | 10 | [![NPM](https://nodei.co/npm/pepper-mint.png?mini=true)](https://nodei.co/npm/pepper-mint/) 11 | 12 | ```javascript 13 | require('pepper-mint')(user, pass, cookie) 14 | .then(function(mint) { 15 | console.log("Logged in..."); 16 | 17 | // return another promise 18 | // (or you can then() it here, of course, 19 | // if you need more API calls) 20 | return mint.getAccounts(); 21 | }) 22 | .then(function(accounts) { 23 | 24 | // accounts is the array of account objects 25 | accounts.forEach(function(account) { 26 | // EG: "Bank of America", "Savings", 1234567 27 | console.log(account.fiName, account.accountName, account.accountId); 28 | }); 29 | }) 30 | .catch(function(err) { 31 | console.error("Boo :(", err); 32 | }); 33 | ``` 34 | 35 | #### Mint Cookie 36 | 37 | > 🚨 NOTE: Mint no longer seems to issue these. This information will 38 | > remain for historical purposes, but you should probably just ignore it and 39 | > only provide the `user` and `pass` parameters to the PepperMint constructor. 40 | > If you do happen to still have `ius_session` and `thx_guid` you can continue 41 | > to provide them, as they do occasionally still work. PepperMint will try 42 | > to use them first, if provided, then fallback to the new auth method 43 | > which always opens a browser. 44 | 45 | Because of an update to the authorization flow of Mint.com, the API now 46 | requires a couple cookies, which are passed to the *pepper-mint* library as 47 | a string. These are called `ius_session` and `thx_guid`. 48 | 49 | To get the value of these cookies, you can use Chrome and login to Mint.com, 50 | then open the Developer Tools and check the Application tab. On the left 51 | should be an item called Cookies, which you can expand to see 52 | `https://mint.intuit.com` and `https://pf.intuit.com`, at least. `ius_session` 53 | can be found on the former, and `thx_guid` can be found on the latter. 54 | 55 | You can pass these separately as: 56 | 57 | ```javascript 58 | require('pepper-mint')(username, password, ius_session, thx_guid) 59 | ``` 60 | 61 | or as a cookie-style string (for backwards compatibility): 62 | 63 | ```javascript 64 | require('pepper-mint')(username, password, 65 | `ius_session=${ius_session};thx_guid=${thx_guid}`) 66 | ``` 67 | 68 | Furthermore, if you don't want to extract them by hand at all, *pepper-mint* 69 | includes a mechanism to drive a Chrome browser and extract it automatically---just 70 | be aware that using this method will probably require you to input a two-factor 71 | auth code. If you want to persist the cookies fetched by this method, they will 72 | be stored as `.sessionCookies` on the Mint instance: 73 | 74 | > 🚨 NOTE: `.sessionCookies` still exists as of PepperMint v2.0.0, but it 75 | > is now an array of `{name: "", value: ""}` maps. Due to how short a 76 | > lifespan the new cookies have, you probably shouldn't bother trying to 77 | > persist them like this anymore. 78 | 79 | ```javascript 80 | require('pepper-mint')(username, password) 81 | .then(function(mint) { 82 | // NOTE: this is spelled out to clarify the format 83 | // of the sessionCookies property 84 | persistCookies({ 85 | // 🚨 Just a reminder, if you missed the NOTE above: 86 | // sessionCookies does not look like this anymore as of 87 | // PepperMint v2.0.0 and you probably shouldn't bother 88 | // with any of this. 89 | ius_session: mint.sessionCookies.ius_session, 90 | thx_guid: mint.sessionCookies.thx_guid, 91 | }); 92 | }); 93 | ``` 94 | 95 | 96 | ### API 97 | 98 | Everything returns a [promise](https://github.com/kriskowal/q) for convenient 99 | chaining (and also because I wanted to try it out). 100 | 101 | #### require('pepper-mint') 102 | 103 | Returns a Login function, which accepts a mint.com username and password 104 | as its arguments, and returns a Promise which, when resolved, passes a 105 | PepperMint API object. All methods below are called on that object, and 106 | return a Promise. In this context, "returns" is a shorthand to mean 107 | "the promise resolves with." 108 | 109 | #### mint.getAccounts() 110 | 111 | Returns an array of Accounts. 112 | 113 | #### mint.getCategories() 114 | 115 | Returns a list of Categories (for categorizing transactions) 116 | 117 | #### mint.getTags() 118 | 119 | Returns a list of user-defined Tags 120 | 121 | #### mint.getTransactions([args]) 122 | 123 | Returns a list of Transactions, optionally filtered by account and/or offset. 124 | `args` is an optional dictionary, with keys `accountId` and `offset`, both 125 | optional. 126 | 127 | #### mint.createTransaction(args) 128 | 129 | Create a new cash transaction. 130 | 131 | NB: There is currently very little arg validation, 132 | and the server seems to silently reject issues, too :( 133 | 134 | Args should look like: 135 | 136 | ```javascript 137 | { 138 | accountId: 1234 // apparently ignored, but good to have, I guess? 139 | amount: 4.2 140 | category: { 141 | id: id 142 | , name: name 143 | } 144 | date: "MM/DD/YYYY" 145 | isExpense: bool 146 | isInvestment: bool 147 | merchant: "Merchant Name" 148 | note: "Note, if any" 149 | tags: [1234, 5678] // set of ids 150 | } 151 | ``` 152 | 153 | `category` is Optional; if not provided, will just show 154 | up as UNCATEGORIZED, it seems 155 | 156 | #### mint.deleteTransaction(transactionId) 157 | 158 | Delete a transaction by its ID 159 | -------------------------------------------------------------------------------- /test/integration-test.js: -------------------------------------------------------------------------------- 1 | let chai = require('chai'); 2 | let PepperMint = require('../dist'); 3 | 4 | chai.should(); 5 | 6 | try { 7 | var config = require('./integration-config.json'); 8 | } catch (e) { 9 | // no config; don't run tests 10 | } 11 | 12 | if (config) { 13 | describe('pepper-mint', function() { 14 | describe('handles editing transactions', function() { 15 | it('verifies fields have changed after editing', async function() { 16 | this.timeout(30000); 17 | let mint = await PepperMint(config.username, config.password, config.ius_session, config.thx_guid); 18 | await createTransaction(mint); 19 | let originalTransaction = (await getTransactions(mint))[0]; 20 | 21 | let transactionUpdates = getTransactionUpdates(originalTransaction); 22 | await editTransactionWithUpdates(mint, transactionUpdates); 23 | let updatedTransaction = (await getTransactions(mint))[0]; 24 | 25 | await deleteTransaction(mint, updatedTransaction); 26 | 27 | await doAssertionsWithUpdates(updatedTransaction, transactionUpdates); 28 | }); 29 | 30 | it('verifies undefined fields when updating will not be cleared after editing', async function() { 31 | this.timeout(30000); 32 | let mint = await PepperMint(config.username, config.password, config.ius_session, config.thx_guid); 33 | await createTransaction(mint); 34 | let originalTransaction = (await getTransactions(mint))[0]; 35 | 36 | let transactionUpdates = getTransactionUpdates(originalTransaction); 37 | setAllOptionalFieldsUndefined(transactionUpdates); 38 | 39 | await editTransactionWithUpdates(mint, transactionUpdates); 40 | let updatedTransaction = (await getTransactions(mint))[0]; 41 | 42 | await deleteTransaction(mint, updatedTransaction); 43 | 44 | await doAssertions(updatedTransaction, originalTransaction); 45 | }); 46 | it('verifies empty fields when updating will not be cleared after editing', async function() { 47 | this.timeout(30000); 48 | let mint = await PepperMint(config.username, config.password, config.ius_session, config.thx_guid); 49 | await createTransaction(mint); 50 | let originalTransaction = (await getTransactions(mint))[0]; 51 | 52 | let transactionUpdates = getTransactionUpdates(originalTransaction); 53 | setAllFieldsEmpty(transactionUpdates); 54 | 55 | await editTransactionWithUpdates(mint, transactionUpdates); 56 | let updatedTransaction = (await getTransactions(mint))[0]; 57 | 58 | await deleteTransaction(mint, updatedTransaction); 59 | 60 | await doAssertions(updatedTransaction, originalTransaction); 61 | }) 62 | }); 63 | }); 64 | } 65 | 66 | function doAssertions(updatedTransaction, originalTransaction) { 67 | updatedTransaction.merchant.should.equal(originalTransaction.merchant); 68 | updatedTransaction.category.should.equal(originalTransaction.category); 69 | updatedTransaction.categoryId.should.equal(originalTransaction.categoryId); 70 | updatedTransaction.date.should.equal(originalTransaction.date); 71 | updatedTransaction.note.should.equal(originalTransaction.note); 72 | } 73 | 74 | function doAssertionsWithUpdates(updatedTransaction, transactionUpdates) { 75 | updatedTransaction.merchant.should.equal(transactionUpdates.merchant); 76 | updatedTransaction.category.should.equal(transactionUpdates.category); 77 | updatedTransaction.categoryId.should.equal(transactionUpdates.categoryId); 78 | updatedTransaction.date.should.equal(formatDate(transactionUpdates.date)); 79 | } 80 | 81 | function createTransaction(mint) { 82 | let createRequest = { 83 | amount: 1.23, 84 | date: "05/04/2010", 85 | merchant: "Test Merchant Name", 86 | note: "This is a test transaction", 87 | }; 88 | return mint.createTransaction(createRequest); 89 | } 90 | 91 | function getTransactions(mint) { 92 | let getTransactionsRequest = { 93 | query: [ 94 | "Test Merchant Name", 95 | ], 96 | startDate: new Date(2010, 4), 97 | endDate: new Date(2010, 6), 98 | }; 99 | return mint.getTransactions(getTransactionsRequest); 100 | } 101 | 102 | function editTransactionWithUpdates(mint, updates) { 103 | return mint.editTransaction(updates); 104 | } 105 | 106 | function getTransactionUpdates(originalTransaction) { 107 | return { 108 | id: originalTransaction.id, 109 | merchant: "New Test Merchant Name", 110 | category: "Vacation", 111 | categoryId: 1504, 112 | date: "05/05/2010", 113 | }; 114 | } 115 | 116 | function deleteTransaction(mint, transactions) { 117 | return mint.deleteTransaction(transactions.id) 118 | } 119 | 120 | function setAllFieldsEmpty(transactionUpdates) { 121 | transactionUpdates.merchant = ""; 122 | transactionUpdates.category = ""; 123 | transactionUpdates.categoryId = ""; 124 | transactionUpdates.date = ""; 125 | } 126 | 127 | function setAllOptionalFieldsUndefined(transactionUpdates) { 128 | transactionUpdates.merchant = undefined; 129 | transactionUpdates.category = undefined; 130 | transactionUpdates.categoryId = undefined; 131 | // Date is a required field 132 | transactionUpdates.date = ""; 133 | } 134 | 135 | function formatDate(currentYearStyledDate) { 136 | let date = new Date(currentYearStyledDate); 137 | let year = date.getFullYear().toString().slice(2); 138 | let month = padLeadingZero(date.getMonth() + 1); 139 | let day = padLeadingZero(date.getDate()); 140 | return `${month}/${day}/${year}` 141 | } 142 | 143 | function padLeadingZero(number) { 144 | return `0${number.toString()}`.slice(-2); 145 | } 146 | -------------------------------------------------------------------------------- /src/core.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | import { IMintAuth, INetService } from "./model"; 4 | import { IMintAccount } from "./model/account"; 5 | import { IBudgetQuery, IMintBudget } from "./model/budget"; 6 | import { IMintCategory } from "./model/category"; 7 | import { IMintProvider, IMintProviderAccount } from "./model/provider"; 8 | import { IMintTag } from "./model/tag"; 9 | import { IMintTransaction, IMintTransactionQuery, INewTransaction, ITransactionEdit } from "./model/transaction"; 10 | import { delayMillis } from "./util/async"; 11 | import { budgetForKey, formatBudgetQuery } from "./util/budget"; 12 | import { Cache } from "./util/cache"; 13 | import { Clock, IClock } from "./util/clock"; 14 | import { stringifyDate } from "./util/date"; 15 | 16 | const INTUIT_API_KEY = "prdakyrespQBtEtvaclVBEgFGm7NQflbRaCHRhAy"; 17 | const INTUIT_URL_BASE = "mas/v1"; 18 | 19 | const DEFAULT_REFRESH_AGE_MILLIS = 24 * 3600 * 1000; // 24 hours 20 | 21 | function accountIsActive(account: IMintAccount | IMintProviderAccount) { 22 | return account.isActive; 23 | } 24 | 25 | export type DoneRefreshingPredicate = (ids: (IMintAccount | IMintProviderAccount)[]) => boolean; 26 | 27 | /** 28 | * Coerce the "doneRefreshing" arg passed to various functions 29 | * into the appropriate predicate 30 | */ 31 | function coerceDoneRefreshing( 32 | arg: number | DoneRefreshingPredicate | undefined, 33 | ): DoneRefreshingPredicate { 34 | if (!arg || typeof(arg) === 'number') { 35 | const maxRefreshingIds = arg || 0; 36 | return (accounts: any[]) => { 37 | return accounts.length <= maxRefreshingIds; 38 | }; 39 | } 40 | 41 | return arg; 42 | } 43 | 44 | export class PepperMint extends EventEmitter { 45 | 46 | private readonly cache = new Cache(); 47 | 48 | // NOTE: this key might not be static; if that's the case, 49 | // we can load overview.event and pull it out of the embedded 50 | // javascript from a JSON object field `browserAuthAPIKey` 51 | private readonly intuitApiKey = INTUIT_API_KEY; 52 | 53 | constructor( 54 | readonly net: INetService, 55 | readonly auth: IMintAuth, 56 | private readonly clock: IClock = new Clock(), 57 | ) { 58 | super(); 59 | } 60 | 61 | public async getAccounts() { 62 | const result = await this.net.jsonForm({ 63 | args: { 64 | types: [ 65 | "BANK", 66 | "CREDIT", 67 | "INVESTMENT", 68 | "LOAN", 69 | "MORTGAGE", 70 | "OTHER_PROPERTY", 71 | "REAL_ESTATE", 72 | "VEHICLE", 73 | "UNCLASSIFIED", 74 | ], 75 | }, 76 | service: "MintAccountService", 77 | task: "getAccountsSorted", 78 | }); 79 | return result as IMintAccount[]; 80 | } 81 | public accounts() { 82 | return this.cache.as("accounts", () => { 83 | return this.getAccounts(); 84 | }); 85 | } 86 | 87 | public async getBudgets(): Promise; 88 | public async getBudgets(query: IBudgetQuery | Date): Promise; 89 | public async getBudgets(arg: IBudgetQuery | Date = new Date()) { 90 | const args = formatBudgetQuery(this.clock, arg); 91 | const [ categories, json ] = await Promise.all([ 92 | this.categories(), 93 | this.net.getJson("getBudget.xevent", args), 94 | ]); 95 | 96 | const data = json.data; 97 | const incomeKeys = Object.keys(data.income).map(key => parseInt(key, 10)); 98 | 99 | if (arg instanceof Date) { 100 | // single month 101 | const budgetKey = Math.min(...incomeKeys).toString(); 102 | return budgetForKey(this, categories, data, budgetKey); 103 | } 104 | 105 | // list of months 106 | incomeKeys.sort(); 107 | return incomeKeys.map(key => 108 | budgetForKey(this, categories, data, key.toString()) 109 | ); 110 | } 111 | 112 | public getCategories(): Promise { 113 | return this.getJsonData("categories"); 114 | } 115 | public categories() { 116 | return this.cache.as("categories", () => { 117 | return this.getCategories(); 118 | }); 119 | } 120 | 121 | public getCategoryNameById(categories: IMintCategory[], id: number) { 122 | if (id === 0) return "Uncategorized"; 123 | 124 | let found: string | null = null; 125 | categories.some(el => { 126 | if (el.id === id) { 127 | found = el.value; 128 | return true; 129 | } 130 | 131 | if (!el.children) return false; 132 | 133 | // there's only one level of depth, so 134 | // no need for recursion 135 | return el.children.some(kid => { 136 | if (kid.id === id) { 137 | found = el.value + ": " + kid.value; 138 | return true; 139 | } 140 | }); 141 | }); 142 | 143 | return found; 144 | }; 145 | 146 | public async getTags(): Promise { 147 | return this.getJsonData("tags"); 148 | } 149 | 150 | public async getTransactions( 151 | query?: IMintTransactionQuery, 152 | ): Promise { 153 | const args = query || {}; 154 | const offset = args.offset || 0; 155 | let queryArray = args.query || []; 156 | if (!Array.isArray(queryArray)) { 157 | queryArray = [queryArray]; 158 | } 159 | if (args.category && typeof args.category === "object") { 160 | args.category = args.category.id; 161 | } 162 | if (args.category) { 163 | queryArray.push(`category:"${args.category}"`); 164 | } 165 | 166 | let startDate: string | undefined; 167 | if (args.startDate) startDate = stringifyDate(args.startDate); 168 | 169 | let endDate: string | undefined; 170 | if (args.endDate) endDate = stringifyDate(args.endDate); 171 | 172 | return this.getJsonData({ 173 | accountId: args.accountId, 174 | offset, 175 | comparableType: 8, // ? 176 | acctChanged: "T", // ? 177 | query: queryArray.join(","), 178 | queryNew: "", 179 | startDate, 180 | endDate, 181 | task: "transactions", 182 | }); 183 | } 184 | 185 | /** 186 | * Create a new cash transaction; to be used to fake transaction 187 | * imports. It is not possible to create non-cash transactions 188 | * associated with a bank account. 189 | * 190 | * NB: There is currently very little arg validation, 191 | * and the server seems to silently reject issues, too :( 192 | */ 193 | public async createTransaction(args: INewTransaction) { 194 | const form: any = { 195 | amount: args.amount, 196 | cashTxnType: 'on', 197 | date: stringifyDate(args.date), 198 | isInvestment: args.isInvestment, 199 | merchant: args.merchant, 200 | mtAccount: args.accountId, 201 | mtCashSplitPref: 2, // ? 202 | mtCheckNo: '', 203 | mtIsExpense: args.isExpense, 204 | mtType: 'cash', 205 | note: args.note, 206 | task: 'txnadd', 207 | txnId: ':0', // might be required 208 | 209 | token: this.auth.token, 210 | }; 211 | 212 | if (args.category) { 213 | form.catId = args.category.id; 214 | form.category = args.category.name; 215 | } 216 | 217 | // set any tags requested 218 | if (Array.isArray(args.tags)) { 219 | for (const tagId of args.tags) { 220 | form["tag" + tagId] = 2; // what? 2?! 221 | } 222 | } 223 | 224 | return this.net.postForm("updateTransaction.xevent", form); 225 | } 226 | 227 | /** 228 | * Delete a transaction by its id 229 | */ 230 | public deleteTransaction(transactionId: number) { 231 | return this.net.postForm('updateTransaction.xevent', { 232 | task: "delete", 233 | txnId: transactionId, 234 | token: this.auth.token, 235 | }); 236 | } 237 | 238 | /** 239 | * Note that the format of the category information is different from 240 | * that for createTransaction. This is to make it simple to just use a 241 | * modified result from `getTransactions()` 242 | */ 243 | public editTransaction(edit: ITransactionEdit) { 244 | const form = { 245 | amount: "", 246 | category: edit.category, 247 | catId: edit.categoryId, 248 | categoryTypeFilter: "null", 249 | date: stringifyDate(edit.date), 250 | merchant: edit.merchant, 251 | txnId: edit.id, 252 | 253 | task: "txnedit", 254 | token: this.auth.token, 255 | }; 256 | 257 | // TODO support tags, adding notes? 258 | // That form is much more complicated... 259 | 260 | return this.net.postForm("updateTransaction.xevent", form); 261 | } 262 | 263 | /** 264 | * DEPRECATED: The name of this method is misleading, but is kept for 265 | * backwards compatibility. You should prefer to use 266 | * [getRefreshingProviderIds] instead. 267 | */ 268 | public async getRefreshingAccountIds() { 269 | return this.getRefreshingProviderIds(); 270 | } 271 | /** 272 | * Check which providers are still refreshing (if any). A provider 273 | * is, for example, the bank at which your account lives. 274 | */ 275 | public async getRefreshingProviderIds(): Promise { 276 | const response = await this.getIntuitJson("/refreshJob"); 277 | return response.refreshingCpProviderIds; 278 | } 279 | 280 | /** 281 | * Convenience to map the result of getRefreshingAccountIds() to 282 | * the actual Accounts (IE: similar to that returned from .accounts()). 283 | * 284 | * NOTE: The actual Account instances will be those from providers(), 285 | * and so some fields will be slightly different than those from 286 | * .accounts(). 287 | */ 288 | public async getRefreshingAccounts(): Promise { 289 | const [providers, refreshingProviderIds] = await Promise.all([ 290 | this.providers(), 291 | this.getRefreshingProviderIds(), 292 | ]); 293 | 294 | const providerById = providers.reduce((m, provider) => { 295 | m[provider.cpProviderId] = provider; 296 | return m; 297 | }, {} as {[key: string]: IMintProvider}); 298 | 299 | // no indication of actually which accounts are specifically being 300 | // refreshed, so we just assume all for a provider 301 | return refreshingProviderIds.map(id => providerById[id]) 302 | .filter(provider => provider) // unknown provider...? 303 | .reduce((result, provider) => { 304 | return result.concat(provider.providerAccounts); 305 | }, [] as IMintProviderAccount[]) 306 | .filter(accountIsActive); 307 | } 308 | 309 | /** 310 | * Get a list of the financial data providers available to this 311 | * Mint user. 312 | */ 313 | public async getProviders(): Promise { 314 | const response = await this.getIntuitJson("/providers"); 315 | return response.providers; 316 | } 317 | public async providers() { 318 | return this.cache.as("providers", () => { 319 | return this.getProviders(); 320 | }); 321 | } 322 | 323 | /** 324 | * Refresh account FI Data 325 | */ 326 | public async initiateAccountRefresh() { 327 | await this.postIntuitJson("/refreshJob", { allProviders: true }); 328 | } 329 | 330 | /** 331 | * This is a convenience function on top of `refreshIfNeeded()` 332 | * and `waitForRefresh()`. Options is an object with keys: 333 | * - maxAgeMillis: see refreshIfNeeded() 334 | * - doneRefreshing: see waitForRefresh() 335 | * - maxRefreshingIds: Deprecated; see waitForRefresh() 336 | * 337 | * @return A Promise that resolves to this PepperMint instance 338 | * when refreshing is done (or if it wasn't needed) 339 | */ 340 | public async refreshAndWaitIfNeeded( 341 | options: { 342 | maxAgeMillis?: number; 343 | doneRefreshing?: DoneRefreshingPredicate; 344 | maxRefreshingIds?: number; 345 | } = {}, 346 | ) { 347 | const waitArg = options.doneRefreshing || options.maxRefreshingIds; 348 | 349 | while (true) { 350 | const didRefresh = await this.refreshIfNeeded( 351 | options.maxAgeMillis, 352 | waitArg, 353 | ); 354 | if (!didRefresh) { 355 | // done! 356 | return this; 357 | } 358 | } 359 | } 360 | 361 | /** 362 | * If any accounts haven't been updated in the last `maxAgeMillis` 363 | * milliseconds (by default, 24 hours), this will initiate an account 364 | * refresh. 365 | * 366 | * @param doneRefreshing As with `waitForRefresh()`. 367 | * @returns A promise that resolves to `true` once the refresh is 368 | * initiated, else `false`. If a refresh *will be* initiated, 369 | * a 'refreshing' event is emitted with a list of the accounts being 370 | * refreshed. 371 | */ 372 | public async refreshIfNeeded( 373 | maxAgeMillis: number = DEFAULT_REFRESH_AGE_MILLIS, 374 | doneRefreshing?: number | DoneRefreshingPredicate, 375 | ) { 376 | maxAgeMillis = maxAgeMillis || DEFAULT_REFRESH_AGE_MILLIS; 377 | doneRefreshing = coerceDoneRefreshing(doneRefreshing); 378 | 379 | const accounts = await this.accounts(); 380 | 381 | const now = this.clock.now().getTime(); 382 | const needRefreshing = accounts.filter(account => { 383 | if (account.isError || account.fiLoginStatus.startsWith("FAILED")) { 384 | // ignore accounts we *can't* refresh 385 | return false; 386 | } 387 | 388 | return now - account.lastUpdated > maxAgeMillis; 389 | }).filter(accountIsActive); 390 | 391 | if (doneRefreshing(needRefreshing)) { 392 | // no refresh needed! 393 | return false; 394 | } else { 395 | this.emit("refreshing", needRefreshing); 396 | await this.initiateAccountRefresh(); 397 | return true; 398 | } 399 | } 400 | 401 | /** 402 | * Wait until an account refresh is completed. This will poll 403 | * `getRefreshingAccount()` every few seconds, and emit a 404 | * 'refreshing' event with the status, then finally resolve 405 | * to this PepperMint instance when done. 406 | * 407 | * @param doneRefreshing A predicate function that takes a list of accounts 408 | * and returns True if refreshing is "done." If not provided, 409 | * this defaults to checking for an empty list---that is, there are no 410 | * more accounts being refreshed. For backwards compatibility, this 411 | * may also be the max number of still-refreshing ids remaining to 412 | * be considered "done." This is 0 by default, of course. 413 | */ 414 | public async waitForRefresh(doneRefreshing?: number | DoneRefreshingPredicate) { 415 | doneRefreshing = coerceDoneRefreshing(doneRefreshing); 416 | 417 | while (true) { 418 | const refreshing = await this.getRefreshingAccounts(); 419 | if (doneRefreshing(refreshing)) { 420 | // done! 421 | return this; 422 | } 423 | 424 | this.emit("refreshing", refreshing); 425 | 426 | await delayMillis(10000); 427 | } 428 | } 429 | 430 | private async getIntuitJson(urlPart: string) { 431 | return this.net.getJson(INTUIT_URL_BASE + urlPart, undefined, { 432 | Authorization: 'Intuit_APIKey intuit_apikey=' + this.intuitApiKey + ', intuit_apikey_version=1.0', 433 | }); 434 | }; 435 | 436 | private async postIntuitJson(urlPart: string, body: any) { 437 | return this.net.postJson(INTUIT_URL_BASE + urlPart, body, { 438 | Authorization: 'Intuit_APIKey intuit_apikey=' + this.intuitApiKey + ', intuit_apikey_version=1.0', 439 | }); 440 | }; 441 | 442 | private async getJsonData(args: string | {[key: string]: any}): Promise { 443 | if (typeof args === "string") { 444 | args = { task: args }; 445 | } 446 | (args as any).rnd = this.clock.now(); 447 | 448 | const json = await this.net.getJson("getJsonData.xevent", args); 449 | return json.set[0].data as T; 450 | } 451 | } 452 | --------------------------------------------------------------------------------