├── tests ├── __init__.py ├── fixtures │ ├── bank_small.ofx │ ├── signon_success_no_message.ofx │ ├── signon_success.ofx │ ├── signon_fail.ofx │ ├── error_message.ofx │ ├── anzcc.ofx │ ├── ofx-v102-empty-tags.ofx │ ├── bank_medium.ofx │ ├── fail_nice │ │ ├── decimal_error.ofx │ │ ├── empty_balance.ofx │ │ └── date_missing.ofx │ ├── account_listing_aggregation.ofx │ ├── suncorp.ofx │ ├── multiple_accounts.ofx │ ├── multiple_accounts2.ofx │ ├── checking.ofx │ ├── vanguard.ofx │ ├── tiaacref.ofx │ ├── vanguard401k.ofx │ ├── investment_medium.ofx │ ├── fidelity-savings.ofx │ ├── investment_401k.ofx │ ├── td_ameritrade.ofx │ └── fidelity.ofx ├── support.py ├── test_write.py └── test_parse.py ├── requirements.txt ├── .coverage ├── .coveragerc ├── MANIFEST.in ├── setup.cfg ├── .gitignore ├── todo.txt ├── ofxparse ├── __init__.py ├── ofxprinter.py ├── ofxutil.py └── ofxparse.py ├── AUTHORS ├── .travis.yml ├── LICENSE ├── setup.py ├── utils └── ofx2xlsx.py └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-coveralls 2 | beautifulsoup4 3 | -------------------------------------------------------------------------------- /.coverage: -------------------------------------------------------------------------------- 1 | # Don't think anything needs to be in this file. 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = 4 | ofxparse 5 | 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE AUTHORS 2 | recursive-include tests *.py *.ofx 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [egg_info] 2 | tag_svn_revision = false 3 | 4 | [aliases] 5 | release = register sdist bdist_egg upload 6 | 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | .DS_Store 4 | ofxparse.egg-info/ 5 | build/ 6 | dist/ 7 | nbproject/ 8 | .coverage 9 | htmlcov 10 | -------------------------------------------------------------------------------- /tests/fixtures/bank_small.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:NONE 10 | 11 | 12 | -------------------------------------------------------------------------------- /todo.txt: -------------------------------------------------------------------------------- 1 | - Add support for account types 2 | Account - BankAccount, CreditAccount, InvestmentAccount 3 | 4 | - Better documentation 5 | Statement 6 | Transaction 7 | Institute 8 | 9 | - Look into using a real sgml parser library like the one on PyPi. 10 | 11 | -------------------------------------------------------------------------------- /tests/support.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def open_file(filename, mode='rb'): 5 | """ 6 | Load a file from the fixtures directory. 7 | """ 8 | path = os.path.join('fixtures', filename) 9 | if 'tests' in os.listdir('.'): 10 | path = os.path.join('tests', path) 11 | return open(path, mode=mode) 12 | -------------------------------------------------------------------------------- /ofxparse/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from .ofxparse import (OfxParser, OfxParserException, AccountType, Account, 4 | Statement, Transaction) 5 | from .ofxprinter import OfxPrinter 6 | 7 | __version__ = '0.21' 8 | __all__ = [ 9 | 'OfxParser', 10 | 'OfxParserException', 11 | 'AccountType', 12 | 'Account', 13 | 'Statement', 14 | 'Transaction', 15 | 'OfxPrinter' 16 | ] 17 | -------------------------------------------------------------------------------- /tests/fixtures/signon_success_no_message.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:3bb6707632b64da196722ef312e6376d 10 | 11 | 0INFO20130325211405.187[-7:MST]ENGAMEX310120130325211405FMPWeb 12 | -------------------------------------------------------------------------------- /tests/fixtures/signon_success.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:3bb6707632b64da196722ef312e6376d 10 | 11 | 0INFOLogin successful20130325211405.187[-7:MST]ENGAMEX310120130325211405FMPWeb 12 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Developers: 2 | Jerry Seutter 3 | 4 | Contributors: 5 | Wes Devauld 6 | Leonardo Santagada 7 | Eric Moritz 8 | James Addison 9 | Matt Haggard 10 | Richart T Guy 11 | Alex Chiang 12 | Matt Haggard 13 | Andre Smolik 14 | greggles@github 15 | mikeivanov@github 16 | danc86@github 17 | Erik Hetzner 18 | Panagiotis Issaris 19 | Brett Trotter 20 | Joe Cabrera 21 | Michael Nelson 22 | Nathan Grigg 23 | Francois Chapuis 24 | Wes Turner 25 | Ehud Ben-Reuven 26 | Joseph Walton 27 | Rehan Khwaja 28 | tuzzeg 29 | -------------------------------------------------------------------------------- /tests/fixtures/signon_fail.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:0e1a88e56fc548a1ba2e83bce6323bb6 10 | 11 | 15500ERRORYour request could not be processed because you supplied an invalid identification code or your password was incorrect20130325211209.350[-7:MST]ENGAMEX31012013032521120915500 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '2.7' 4 | - '3.5' 5 | - '3.6' 6 | - '3.7' 7 | - '3.8' 8 | install: 9 | - if [[ $TRAVIS_PYTHON_VERSION == 2* ]]; then pip install BeautifulSoup six nose coverage 10 | python-coveralls; fi 11 | - if [[ $TRAVIS_PYTHON_VERSION == 3* ]]; then pip install BeautifulSoup4 six nose 12 | coverage python-coveralls; fi 13 | script: 14 | - nosetests 15 | after_success: 16 | - coveralls 17 | deploy: 18 | provider: pypi 19 | edge: true 20 | user: jseutter 21 | password: 22 | secure: buE5iS5WhggpFcqR7iIEfcnDNHGeZ4zcYlgy3p9mJKEP8s7NMVeYJc+0FnnNs2fOEVR1QUX/URFtAZegtW9Bi/hVSc2bECZxM75uH342vqtea2rNJ7wQLSugUO+w9Q7HvC2KqeVl3s5Qa4Y3+mwv3Ej4tPI/WfASaNZG3XkwX4c= 23 | on: 24 | tags: true 25 | distributions: sdist bdist_wheel 26 | repo: jseutter/ofxparse 27 | skip_existing: true 28 | -------------------------------------------------------------------------------- /tests/fixtures/error_message.ofx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 0 8 | INFO 9 | SUCCESS 10 | 11 | 20180521052952.749[-7:PDT] 12 | ENG 13 | 14 | svb.com 15 | 944 16 | 17 | 18 | 19 | 20 | 21 | ae91f50f-f16d-4bc1-b88f-2a7fa04b6de1 22 | 23 | 2000 24 | ERROR 25 | General Server Error 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/fixtures/anzcc.ofx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 0 8 | INFO 9 | 10 | 20170510192849 11 | ENG 12 | 13 | 14 | 15 | 16 | 1 17 | 18 | 0 19 | INFO 20 | 21 | 22 | AUD 23 | 24 | 1234123412341234 25 | 26 | 27 | 20170311 28 | 20170509 29 | 30 | DEBIT 31 | 20170508000000 32 | 20170508000000 33 | -5.50 34 | 201705080001 35 | SOME MEMO 36 | 37 | 38 | 39 | -123.45 40 | 20170510192849 41 | 42 | 43 | 123.45 44 | 20170510192849 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Jerry Seutter 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /tests/fixtures/ofx-v102-empty-tags.ofx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | OFXHEADER:100 14 | DATA:OFXSGML 15 | VERSION:102 16 | SECURITY:NONE 17 | ENCODING:USASCII 18 | CHARSET:1252 19 | COMPRESSION:NONE 20 | OLDFILEUID:NONE 21 | NEWFILEUID:NONE 22 | 23 | 0INFO20180804093914:01400INFONPBS123456782018050620180804Credit2018050712.3420180507NoUncategorised123.45CBA:Transfer1.0000AUD -------------------------------------------------------------------------------- /tests/test_write.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from ofxparse import OfxParser, OfxPrinter 4 | from unittest import TestCase 5 | from six import StringIO 6 | from os import close, remove 7 | from tempfile import mkstemp 8 | import sys 9 | sys.path.append('..') 10 | from .support import open_file 11 | 12 | 13 | class TestOfxWrite(TestCase): 14 | def test_write(self): 15 | with open_file('fidelity.ofx') as f: 16 | ofx = OfxParser.parse(f) 17 | self.assertEqual(str(ofx), "") 18 | 19 | def test_using_ofx_printer(self): 20 | with open_file('checking.ofx') as f: 21 | ofx = OfxParser.parse(f) 22 | fd, name = mkstemp() 23 | close(fd) 24 | printer = OfxPrinter(ofx=ofx, filename=name) 25 | printer.write(tabs=1) 26 | 27 | def test_using_ofx_printer_with_stringio(self): 28 | with open_file('checking.ofx') as f: 29 | ofx = OfxParser.parse(f) 30 | output_buffer = StringIO() 31 | printer = OfxPrinter(ofx=ofx, filename=None) 32 | printer.writeToFile(output_buffer, tabs=1) 33 | assert output_buffer.getvalue().startswith("OFXHEADER") 34 | 35 | if __name__ == "__main__": 36 | import unittest 37 | unittest.main() 38 | -------------------------------------------------------------------------------- /tests/fixtures/bank_medium.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:NONE 10 | 11 | 0INFOOK20090523122017ENG200905231220172009052312201700024 12 | 200905231220170INFOOK 13 | CAD1600001000012300 000012345678CHECKING 14 | 2009040120090523122017 15 | POS20090401122017.000[-5:EST]-6.600000123456782009040100001MCDONALD'S #112POS MERCHANDISE;MCDONALD'S #112 16 | CHECK20090402122017.000[-5:EST]-316.6700001234567820090402000040Joe's Bald HairstylesMISCELLANEOUS PAYMENTS;Joe's Bald Hairstyles 17 | POS20090403122017.000[-5:EST]-22.000000123456782009040300005CONNIE'S HAIR DPOS MERCHANDISE;CONNIE'S HAIR D 18 | 382.3420090523122017682.3420090523122017 19 | -------------------------------------------------------------------------------- /tests/fixtures/fail_nice/decimal_error.ofx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 0 8 | INFO 9 | 10 | 20110614 11 | 12 | 13 | 14 | 15 | 16 | 1 17 | 18 | 0 19 | INFO 20 | 21 | 22 | CAD 23 | 24 | 123845030 25 | 192639749 26 | CHECKING 27 | 28 | 29 | 30 | 20110412 31 | 20110614 32 | 33 | 34 | 35 | OTHER 36 | 201120000000 37 | $120 38 | 2000957249 39 | Fail1 40 | 41 | 42 | 43 | 44 | 45 | 0 46 | 20110614 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /tests/fixtures/account_listing_aggregation.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:85230611d6fc414fa391a8c2425f8e9e 10 | 11 | 12 | 13 | 14 | 0INFOSuccess 15 | 20120814060142ENG 16 | USAA24591 17 | 18 | 19 | 20 | 21 | 09ca62d0198049388252f0a547bae86a 22 | 0INFOSuccess 23 | 4 24 | 20120814120000 25 | USAA SAVINGS 26 | 27 | 3140742690000000001SAVINGS 28 | YNNACTIVE 29 | 30 | 31 | FOUR STAR CHECKING 32 | 33 | 3140742690000000002CHECKING 34 | YNNACTIVE 35 | 36 | 37 | LINE OF CREDIT 38 | 39 | 31407426900000000000003CREDITLINE 40 | YNNACTIVE 41 | 42 | 43 | MY CREDIT CARD 44 | 45 | 4111111111111111 46 | YNNACTIVE 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /tests/fixtures/fail_nice/empty_balance.ofx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 0 6 | INFO 7 | 8 | 20110614 9 | 10 | 11 | 12 | 13 | 14 | 1 15 | 16 | 0 17 | INFO 18 | 19 | 20 | CAD 21 | 22 | 123845030 23 | 192639749 24 | CHECKING 25 | 26 | 27 | 20110412 28 | 20110614 29 | 30 | OTHER 31 | 20110308020000 32 | 120 33 | 2000957249 34 | Foobar 35 | 36 | 37 | 38 | 39 | 20110614 40 | 41 | 42 | 43 | 20110614 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /tests/fixtures/suncorp.ofx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 0 8 | INFO 9 | 10 | 20131215 11 | ENG 12 | 13 | SUNCORP 14 | 484-799 15 | 16 | 17 | 18 | 19 | 20 | 1 21 | 22 | 0 23 | INFO 24 | 25 | 26 | AUD 27 | 28 | SUNCORP 29 | 123456789 30 | CHECKING 31 | 32 | 33 | 20130618 34 | 20131215 35 | 36 | DEBIT 37 | 20131215 38 | -16.85 39 | 1 40 | 0 41 | 42 | 43 | 44 | 45 | 46 | 1234.12 47 | 20131215 48 | 49 | 50 | 1234.12 51 | 20131215 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /tests/fixtures/multiple_accounts.ofx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 0 8 | INFO 9 | The operation succeeded. 10 | 11 | 20120603203135.547[-7:PDT] 12 | ENG 13 | 14 | blah 15 | 1000 16 | 17 | 18 | 19 | 20 | 21 | 1001 22 | 23 | 0 24 | INFO 25 | 26 | 27 | USD 28 | 29 | 123 30 | 00 31 | 9100 32 | CHECKING 33 | 34 | 35 | 111 36 | 20120603133220.000[-7:PDT] 37 | 38 | 39 | 40 | 41 | 1002 42 | 43 | 0 44 | INFO 45 | 46 | 47 | USD 48 | 49 | 123 50 | 00 51 | 9200 52 | SAVINGS 53 | 54 | 55 | 222 56 | 20120603133220.000[-7:PDT] 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /tests/fixtures/multiple_accounts2.ofx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 0 8 | INFO 9 | The operation succeeded. 10 | 11 | 20120603203135.547[-7:PDT] 12 | ENG 13 | 14 | blah 15 | 1000 16 | 17 | 18 | 19 | 20 | 21 | 1001 22 | 23 | 0 24 | INFO 25 | 26 | 27 | USD 28 | 29 | 123 30 | 00 31 | 9100 32 | CHECKING 33 | 34 | 35 | 111 36 | 20120603133220.000[-7:PDT] 37 | 38 | 39 | 40 | 41 | 1002 42 | 43 | 0 44 | INFO 45 | 46 | 47 | USD 48 | 49 | 123 50 | 00 51 | 9200 52 | SAVINGS 53 | 54 | 55 | 222 56 | 20120603133220.000[-7:PDT] 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /tests/fixtures/checking.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:NONE 10 | 11 | 12 | 13 | 14 | 15 | 0 16 | INFO 17 | 18 | 20130525225731.258 19 | ENG 20 | 20050531060000.000 21 | 22 | FAKE 23 | 1101 24 | 25 | 51123 26 | 9774652 27 | 28 | 29 | 30 | 31 | 0 32 | 33 | 0 34 | INFO 35 | 36 | 37 | USD 38 | 39 | 5472369148 40 | 1452687~7 41 | CHECKING 42 | 43 | 44 | 20000101070000.000 45 | 20130525060000.000 46 | 47 | CREDIT 48 | 20110331120000.000 49 | 0.01 50 | 0000486 51 | DIVIDEND EARNED FOR PERIOD OF 03 52 | DIVIDEND EARNED FOR PERIOD OF 03/01/2011 THROUGH 03/31/2011 ANNUAL PERCENTAGE YIELD EARNED IS 0.05% 53 | 54 | 55 | DEBIT 56 | 20110405120000.000 57 | -34.51 58 | 0000487 59 | AUTOMATIC WITHDRAWAL, ELECTRIC BILL 60 | AUTOMATIC WITHDRAWAL, ELECTRIC BILL WEB(S ) 61 | 62 | 63 | CHECK 64 | 20110407120000.000 65 | -25.00 66 | 0000488 67 | 319 68 | RETURNED CHECK FEE, CHECK # 319 69 | RETURNED CHECK FEE, CHECK # 319 FOR $45.33 ON 04/07/11 70 | 71 | 72 | 73 | 100.99 74 | 20130525225731.258 75 | 76 | 77 | 75.99 78 | 20130525225731.258 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /tests/fixtures/vanguard.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 10 | 11 | 0INFOSuccessful Sign On20110727001702[-5:EST]ENG20010918083000The Vanguard Groupa0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a00INFO420110727USDvanguard.com0123456789020110625160000.000[-5:EST]20110727160000.000[-5:EST]01234567890.0123.07152011.020110715160000.000[-5:EST]20110715160000.000[-5:EST]THIS IS A MEMO012345678CUSIP-42.123100.004212.3CASHOTHERSELL012345678CUSIPOTHERLONG102.0100.0010200.020110726160000.000[-5:EST]Price as of date based on closing priceYY012345678CUSIPOTHERLONG142.2100.4214279.7220110726160000.000[-5:EST]Price as of date based on closing priceYY012345678CUSIPName of the securityVFINX012254.0Price as of date based on closing priceOPENEND012345678CUSIPName of shareVFIAX0123123.45Price as of date based on closing priceOPENEND -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | import codecs 3 | import os 4 | import re 5 | import sys 6 | 7 | from setuptools import setup, find_packages 8 | 9 | # Read the version from __init__ to avoid importing ofxparse while installing. 10 | # This lets the install work when the user does not have BeautifulSoup 11 | # installed. 12 | VERSION = re.search(r"__version__ = '(.*?)'", 13 | open("ofxparse/__init__.py").read()).group(1) 14 | 15 | REQUIRES = [ 16 | "beautifulsoup4", 17 | "lxml", 18 | 'six', 19 | ] 20 | 21 | README = os.path.join(os.path.dirname(__file__), 'README.rst') 22 | 23 | with codecs.open(README, encoding='utf8') as f: 24 | LONG_DESCRIPTION = f.read() 25 | 26 | setup_params = dict( 27 | name='ofxparse', 28 | version=VERSION, 29 | description=("Tools for working with the OFX (Open Financial Exchange)" 30 | " file format"), 31 | long_description=LONG_DESCRIPTION, 32 | # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers 33 | classifiers=[ 34 | "Development Status :: 4 - Beta", 35 | "Intended Audience :: Developers", 36 | "Natural Language :: English", 37 | "Operating System :: OS Independent", 38 | "Programming Language :: Python :: 2.7", 39 | "Programming Language :: Python :: 3", 40 | "Programming Language :: Python :: 3.4", 41 | "Programming Language :: Python :: 3.5", 42 | "Programming Language :: Python :: 3.6", 43 | "Topic :: Software Development :: Libraries :: Python Modules", 44 | "Topic :: Utilities", 45 | "License :: OSI Approved :: MIT License", 46 | ], 47 | keywords='ofx, Open Financial Exchange, file formats', 48 | author='Jerry Seutter', 49 | author_email='jseutter.ofxparse@gmail.com', 50 | url='http://sites.google.com/site/ofxparse', 51 | license='MIT License', 52 | packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), 53 | include_package_data=True, 54 | zip_safe=True, 55 | install_requires=REQUIRES, 56 | entry_points=""" 57 | """, 58 | test_suite='tests', 59 | ) 60 | 61 | if __name__ == '__main__': 62 | setup(**setup_params) 63 | -------------------------------------------------------------------------------- /tests/fixtures/fail_nice/date_missing.ofx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 0 8 | INFO 9 | 10 | 20110614 11 | 12 | 13 | 14 | 15 | 16 | 1 17 | 18 | 0 19 | INFO 20 | 21 | 22 | USD 23 | 24 | 123845030 25 | 192639749 26 | CHECKING 27 | 28 | 29 | 30 | 20110412 31 | 20110614 32 | 33 | 34 | OTHER 35 | -80.00 36 | 184997056 37 | TestFail1 38 | 39 | 40 | 41 | OTHER 42 | 43 | 200.00 44 | 2000957249 45 | TestFail2 46 | 47 | 48 | 49 | OTHER 50 | 20120231 51 | 200.00 52 | 2000957249 53 | TestFail2 54 | 55 | 56 | 57 | 58 | 59 | 0 60 | 20110614 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /tests/fixtures/tiaacref.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 10 | 11 | 0INFOThe operation succeeded.20170308020026.712[-5:EST]ENGTIAA-CREF1304bb4829bb4829bb4829bb4829bb4829bb0INFO420170308020027.199[-5:EST]USDTIAA-CREF.ORG111A1111 22B222 33C33320170204230100.000[-5:EST]20170307230100.000[-5:EST]TIAA#20170307160000.000[-4:EDT]160000.000[-4:EDT]20170307150000.000[-5:EST]20170307150000.000[-5:EST]TIAA Traditional Balance Update111111111CUSIPCASH0INLONG1222222126CUSIPCASHLONG13.07631.000013.076320170307150000.000[-5:EST]222222217CUSIPCASHLONG1.000025.578525.578520170307150000.000[-5:EST]222222233CUSIPCASHLONG8.760512.4823109.351220170307150000.000[-5:EST]222222258CUSIPCASHLONG339.201212.34564187.642320170307150000.000[-5:EST]111111111CUSIPCASHLONG543.711543.7120170307150000.000[-5:EST]333333200CUSIPCASHLONG2.0010.0020.0020170307150000.000[-5:EST]000333333200CUSIPTIAA Real EstateQREARX222222233CUSIPCREF Bond Market R3QCBMIX111111111CUSIPTIAA TraditionalTIAAtrad333333126CUSIPCREF Stock R3QCSTIX333333258CUSIPCREF Equity Index R3QCEQIX333333217CUSIPCREF Money Market R3QCMMIX -------------------------------------------------------------------------------- /tests/fixtures/vanguard401k.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:NONE 10 | 11 | 0INFOSuccessful Sign On20141018150740[-5:EST]ENG20140605083000Vanguard84022foo84022USER34500INFO20141017160000.000[-5:EST]USDvanguard.com012345620140916160000.000[-5:EST]20141018150740.000[-5:EST]1234567890123456790AAA20140926160000.000[-5:EST]20140926160000.000[-5:EST]Price as of date based on closing price92202V351CUSIP14.6113746.06-673.0CASHOTHERPRETAXBUY1234567890123456791AAA20140926160000.000[-5:EST]20140926160000.000[-5:EST]Price as of date based on closing price92202V351CUSIP7.3056846.06-336.5CASHOTHERMATCHBUY1234567890123456793AAA20141010160000.000[-5:EST]20141010160000.000[-5:EST]Price as of date based on closing price92202V351CUSIP15.2503944.13-673.0CASHOTHERPRETAXBUY1234567890123456794AAA20141010160000.000[-5:EST]20141010160000.000[-5:EST]Price as of date based on closing price92202V351CUSIP7.6251944.13-336.5CASHOTHERMATCHBUY1234567890123456795AAA20130905160000.000[-5:EST]20130906160000.000[-5:EST]Investment Expense92202V351CUSIPCASH-0.04241OUTLONG39.37MATCH92202V351CUSIPOTHERLONG117.50644.015171.4420141017160000.000[-5:EST]Price as of date based on closing priceOTHERNONVESTYYGOOGLE INC. 401(K) SAVINGS PLAN100.00.00.00.00.00.00.00.00.00.092202V351CUSIPTarget Retirement 2050 Trust Plus165944.0120141017160000.000[-5:EST]Price as of date based on closing price 12 | -------------------------------------------------------------------------------- /tests/fixtures/investment_medium.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:NONE 10 | 11 | 12 | 13 | 14 | 0 15 | INFO 16 | 17 | 20091217162416.000[-:EST] 18 | ENG 19 | 20 | REDACTEDINC-US 21 | 1234 22 | 23 | 24 | 25 | 26 | 27 | 0 28 | 29 | 0 30 | INFO 31 | 32 | 33 | 20091215202000.000[-4:EST] 34 | CAD 35 | 36 | 404 37 | ABC123 38 | 39 | 40 | 20091214202000.000[-5:EST] 41 | 20091215202000.000[-5:EST] 42 | 43 | 44 | DEBIT 45 | 20091215202000.000[-4:EST] 46 | -3.65 47 | 20091215.U489357.e.USD.1510480481 48 | CASH TRADE: AUD.USD 49 | 50 | 1.06 51 | USD 52 | 53 | 54 | CASH 55 | 56 | 57 | 58 | CREDIT 59 | 20091215202000.000[-4:EST] 60 | 3.35 61 | 20091215.U489357.e.USD.1510982018 62 | CASH TRADE: AUD.USD 63 | 64 | 1.06 65 | USD 66 | 67 | 68 | CASH 69 | 70 | 71 | 72 | DEBIT 73 | 20091215202000.000[-4:EST] 74 | -3.65 75 | 20091215.U489357.e.USD.1511863617 76 | CASH TRADE: AUD.USD 77 | 78 | 1.06 79 | USD 80 | 81 | 82 | CASH 83 | 84 | 85 | 86 | 1.00 87 | 0 88 | 0 89 | 90 | 91 | ENDING CASH 92 | ENDING CASH BALANCE 93 | NUMBER 94 | 2.00 95 | 96 | 97 | STOCK VALUE 98 | TOTAL EQUITY IN STOCKS 99 | NUMBER 100 | 0.00 101 | 102 | 103 | OPTION VALUE 104 | TOTAL EQUITY IN OPTIONS 105 | NUMBER 106 | 0.00 107 | 108 | 109 | IBGROUPNOTES VALUE 110 | TOTAL EQUITY IN IBGROUPNOTES 111 | NUMBER 112 | 0.00 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /tests/fixtures/fidelity-savings.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 10 | 11 | 12 | 13 | 14 | 15 | 16 | 0 17 | INFO 18 | SUCCESS 19 | 20 | 20120908190849.317[-4:EDT] 21 | ENG 22 | 23 | fidelity.com 24 | 7776 25 | 26 | 27 | 28 | 29 | 30 | 00000000000000000000000001 31 | 32 | 0 33 | INFO 34 | SUCCESS 35 | 36 | 37 | 20120908190851.317[-4:EDT] 38 | USD 39 | 40 | fidelity.com 41 | X0000001 42 | 43 | 44 | 20120710000000.000[-4:EDT] 45 | 20120908190849.555[-4:EDT] 46 | 47 | 48 | CHECK 49 | 20120720000000.000[-4:EDT] 50 | -00000000001500.0000 51 | X0000000000000000000001 52 | 0000001001 53 | Check Paid #0000001001 54 | Check Paid #0000001001 55 | 56 | 1.00 57 | USD 58 | 59 | 60 | CASH 61 | 62 | 63 | 64 | DEP 65 | 20120727000000.000[-4:EDT] 66 | +00000000000115.8331 67 | X0000000000000000000002 68 | TRANSFERRED FROM VS X10-08144 69 | TRANSFERRED FROM VS X10-08144-1 70 | 71 | 1.00 72 | USD 73 | 74 | 75 | CASH 76 | 77 | 78 | 79 | PAYMENT 80 | 20120727000000.000[-4:EDT] 81 | -00000000000197.1063 82 | X0000000000000000000003 83 | BILL PAYMENT CITICORP CH 84 | BILL PAYMENT CITICORP CHOICE /0001/N******** 85 | 86 | 1.00 87 | USD 88 | 89 | 90 | CASH 91 | 92 | 93 | 94 | CASH 95 | 20120727000000.000[-4:EDT] 96 | -00000000000197.1220 97 | X0000000000000000000004 98 | DIRECT DEBIT HOMES 99 | DIRECT DEBIT HOMESTREET LS LOAN PMT 100 | 101 | 1.00 102 | USD 103 | 104 | 105 | CASH 106 | 107 | 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /utils/ofx2xlsx.py: -------------------------------------------------------------------------------- 1 | from ofxparse import OfxParser 2 | import pandas as pd 3 | 4 | import argparse 5 | 6 | # TODO automatically extract from transactions 7 | fields = ['id','type', 'date', 'memo', 'payee', 'amount', 'checknum', 'mcc'] 8 | 9 | parser = argparse.ArgumentParser(description='Convert multiple .qfx or .ofx to' 10 | ' .xlsx.\n' 11 | 'Remove duplicate transactions ' 12 | 'from different files.\n' 13 | 'use fixed columns:' 14 | ' %s'%', '.join(fields)) 15 | parser.add_argument('files', metavar='*.ofx *.qfx', type=str, nargs='+', 16 | help='.qfx or .ofx file names') 17 | parser.add_argument('--start', type=str, metavar='2014-01-01', 18 | default='2014-01-01', 19 | help="Don't take transaction before this date") 20 | parser.add_argument('--end', type=str, metavar='2014-12-31', 21 | default='2014-12-31', 22 | help="Don't take transaction after this date") 23 | parser.add_argument('--output', metavar='output.xlsx', type=str, 24 | default='output.xlsx', help='Were to store the xlsx') 25 | parser.add_argument('--id-length', metavar='24', type=int, default=24, 26 | help='Truncate the number of digits in a transaction ID.' 27 | ' This is important because this program remove' 28 | ' transactions with duplicate IDs (after verifing' 29 | ' that they are identical.' 30 | ' If you feel unsafe then use a large number but' 31 | 'usually the last digits of the transaction ID are' 32 | 'running numbers which change from download to download' 33 | ' as a result you will have duplicate transactions' 34 | ' unless you truncate the ID.') 35 | 36 | 37 | args = parser.parse_args() 38 | 39 | 40 | data = {} 41 | for fname in args.files: 42 | ofx = OfxParser.parse(file(fname)) 43 | for account in ofx.accounts: 44 | df = data.get(account.number, pd.DataFrame(columns=fields+['fname'])) 45 | for transaction in account.statement.transactions: 46 | s = pd.Series([getattr(transaction,f) for f in fields], index=fields) 47 | s['fname'] = fname.split('/')[-1] 48 | df = df.append(s, ignore_index=True) 49 | df['id'] = df['id'].str[:args.id_length] # clip the last part of the ID which changes from download to download 50 | data[account.number] = df 51 | 52 | print "Writing result to", args.output 53 | writer = pd.ExcelWriter(args.output) 54 | 55 | for account_number, df in data.iteritems(): 56 | # A transaction is identified using all `fields` 57 | # collapse all repeated transactions from the same file into one row 58 | # find the number of repeated transactions and 59 | # put it in samedayrepeat column 60 | df_count = df.groupby(fields+['fname']).size() 61 | df_count = df_count.reset_index() 62 | df_count.columns = list(df_count.columns[:-1]) + ['samedayrepeat'] 63 | 64 | # two transactions from the same file are always different 65 | # but the same transaction can appear in multiple files if they overlap. 66 | # check we have the same samedayrepeat for the same transaction on different files 67 | df_size_fname_count = df_count.reset_index().groupby(fields).samedayrepeat.nunique() 68 | assert (df_size_fname_count == 1).all(), "Different samedayrepeat in different files" 69 | 70 | # take one file as an example 71 | df1 = df_count.reset_index().groupby(fields+['samedayrepeat']).first() 72 | df1 = df1.reset_index() 73 | 74 | # expand back the collapsed transactions 75 | # duplicate rows according to samedayrepeat value 76 | df2 = df1.copy() 77 | for i in range(2,df1.samedayrepeat.max()+1): 78 | df2 = df2.append(df1[i<=df1.samedayrepeat]) 79 | 80 | # sort according to date 81 | df2 = df2.reset_index().set_index('date').sort_index() 82 | # filter dates 83 | df2 = df2.ix[args.start:args.end] 84 | 85 | #cleanup 86 | df2 = df2.reset_index()[fields] 87 | 88 | df2.to_excel(writer, account_number, index=False) 89 | 90 | writer.save() 91 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ofxparse 2 | ======== 3 | 4 | ofxparse is a parser for Open Financial Exchange (.ofx) format files. OFX 5 | files are available from almost any online banking site, so they work well 6 | if you want to pull together your finances from multiple sources. Online 7 | trading accounts also provide account statements in OFX files. 8 | 9 | There are three different types of OFX files, called BankAccount, 10 | CreditAccount and InvestmentAccount files. This library has been tested with 11 | real-world samples of all three types. If you find a file that does not work 12 | with this library, please consider contributing the file so ofxparse can be 13 | improved. See the Help! section below for directions on how to do this. 14 | 15 | Example Usage 16 | ============= 17 | 18 | Here's a sample program 19 | 20 | .. code:: python 21 | 22 | from ofxparse import OfxParser 23 | with codecs.open('file.ofx') as fileobj: 24 | ofx = OfxParser.parse(fileobj) 25 | 26 | # The OFX object 27 | 28 | ofx.account # An Account object 29 | 30 | # AccountType 31 | # (Unknown, Bank, CreditCard, Investment) 32 | 33 | # Account 34 | 35 | account = ofx.account 36 | account.account_id # The account number 37 | account.number # The account number (deprecated -- returns account_id) 38 | account.routing_number # The bank routing number 39 | account.branch_id # Transit ID / branch number 40 | account.type # An AccountType object 41 | account.statement # A Statement object 42 | account.institution # An Institution object 43 | 44 | # InvestmentAccount(Account) 45 | 46 | account.brokerid # Investment broker ID 47 | account.statement # An InvestmentStatement object 48 | 49 | # Institution 50 | 51 | institution = account.institution 52 | institution.organization 53 | institution.fid 54 | 55 | # Statement 56 | 57 | statement = account.statement 58 | statement.start_date # The start date of the transactions 59 | statement.end_date # The end date of the transactions 60 | statement.balance # The money in the account as of the statement date 61 | statement.available_balance # The money available from the account as of the statement date 62 | statement.transactions # A list of Transaction objects 63 | 64 | # InvestmentStatement 65 | 66 | statement = account.statement 67 | statement.positions # A list of Position objects 68 | statement.transactions # A list of InvestmentTransaction objects 69 | 70 | # Transaction 71 | 72 | for transaction in statement.transactions: 73 | transaction.payee 74 | transaction.type 75 | transaction.date 76 | transaction.user_date 77 | transaction.amount 78 | transaction.id 79 | transaction.memo 80 | transaction.sic 81 | transaction.mcc 82 | transaction.checknum 83 | 84 | # InvestmentTransaction 85 | 86 | for transaction in statement.transactions: 87 | transaction.type 88 | transaction.tradeDate 89 | transaction.settleDate 90 | transaction.memo 91 | transaction.security # A Security object 92 | transaction.income_type 93 | transaction.units 94 | transaction.unit_price 95 | transaction.comission 96 | transaction.fees 97 | transaction.total 98 | transaction.tferaction 99 | 100 | # Positions 101 | 102 | for position in statement.positions: 103 | position.security # A Security object 104 | position.units 105 | position.unit_price 106 | position.market_value 107 | 108 | # Security 109 | 110 | security = transaction.security 111 | # or 112 | security = position.security 113 | security.uniqueid 114 | security.name 115 | security.ticker 116 | security.memo 117 | 118 | 119 | Help! 120 | ===== 121 | 122 | Sample ``.ofx`` and ``.qfx`` files are very useful. If you want to help us out, 123 | please edit all identifying information from the file and then email it to 124 | jseutter dot ofxparse at gmail dot com. 125 | 126 | Development 127 | =========== 128 | 129 | Prerequisites:: 130 | # Ubuntu 131 | sudo apt-get install python-beautifulsoup python-nose python-coverage-test-runner 132 | # Python 3 (pip) 133 | pip install BeautifulSoup4 six lxml nose coverage 134 | # Python 2 (pip) 135 | pip install BeautifulSoup six nose coverage 136 | 137 | The `six` package is required for python 2.X compatibility 138 | 139 | Tests: 140 | Simply running the ``nosetests`` command should run the tests. 141 | 142 | .. code:: bash 143 | 144 | nosetests 145 | 146 | If you don't have nose installed, the following might also work: 147 | 148 | .. code:: bash 149 | 150 | python -m unittest tests.test_parse 151 | 152 | Test Coverage Report: 153 | 154 | .. code:: bash 155 | 156 | coverage run -m unittest tests.test_parse 157 | 158 | # text report 159 | coverage report 160 | 161 | # html report 162 | coverage html 163 | firefox htmlcov/index.html 164 | 165 | 166 | Homepage 167 | ======== 168 | | Homepage: https://sites.google.com/site/ofxparse 169 | | Source: https://github.com/jseutter/ofxparse 170 | 171 | License 172 | ======= 173 | 174 | ofxparse is released under an MIT license. See the LICENSE file for the actual 175 | license text. The basic idea is that if you can use Python to do what you are 176 | doing, you can also use this library. 177 | 178 | -------------------------------------------------------------------------------- /tests/fixtures/investment_401k.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:NONE 10 | 11 | 12 | 13 | 14 | 15 | 0 16 | INFO 17 | SUCCESS 18 | 19 | 20150909084609.717[-6:MDT] 20 | ENG 21 | 22 | EXAMPLE 23 | 1234 24 | 25 | 1234 26 | 27 | 28 | 29 | 30 | 0 31 | 32 | 0 33 | INFO 34 | SUCCESS 35 | 36 | 37 | 20140630000000.000[-6:MDT] 38 | USD 39 | 40 | example.org 41 | 12345678.123456-01 42 | 43 | 44 | 20140401000000.000[-6:MDT] 45 | 20140630000000.000[-6:MDT] 46 | 47 | 48 | 49 | 1 50 | 20140617000000.000[-6:MDT] 51 | 52 | 53 | FOO 54 | PRIVATE 55 | 56 | 8.846699 57 | 22.2908 58 | -197.2 59 | OTHER 60 | OTHER 61 | 62 | BUY 63 | 64 | 65 | 66 | 2 67 | 20140630000000.000[-6:MDT] 68 | 69 | 70 | BAR 71 | PRIVATE 72 | 73 | OTHER 74 | 6.800992 75 | IN 76 | LONG 77 | 29.214856 78 | 79 | 80 | 81 | 3 82 | 20140630000000.000[-6:MDT] 83 | 84 | 85 | BAZ 86 | PRIVATE 87 | 88 | OTHER 89 | -9.060702 90 | OUT 91 | LONG 92 | 21.928764 93 | 94 | 95 | 96 | 97 | 98 | 99 | FOO 100 | PRIVATE 101 | 102 | CASH 103 | LONG 104 | 17.604312 105 | 22.517211 106 | 396.4 107 | 20140630000000.000[-6:MDT] 108 | 109 | 110 | 111 | 112 | 113 | BAR 114 | PRIVATE 115 | 116 | CASH 117 | LONG 118 | 13.550983 119 | 29.214855 120 | 395.89 121 | 20140630000000.000[-6:MDT] 122 | 123 | 124 | 125 | 126 | 127 | BAZ 128 | PRIVATE 129 | 130 | CASH 131 | LONG 132 | 0.0 133 | 0.0 134 | 0.0 135 | 20140630000000.000[-6:MDT] 136 | 137 | 138 | 139 | 140 | 1000.00 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | BAR 151 | PRIVATE 152 | 153 | BAR Index Fund 154 | BAR 155 | 156 | 157 | 158 | 159 | 160 | FOO 161 | PRIVATE 162 | 163 | Foo Index Fund 164 | FOO 165 | 166 | 167 | 168 | 169 | 170 | BAZ 171 | PRIVATE 172 | 173 | Baz Fund 174 | BAZ 175 | 176 | 177 | 178 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /tests/fixtures/td_ameritrade.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:11111111111111111111111111111111 10 | 11 | 12 | 13 | 14 | 15 | 0 16 | INFO 17 | Success 18 | 19 | 20171203121212 20 | ENG 21 | 22 | ameritrade.com 23 | 5024 24 | 25 | 26 | 27 | 28 | 29 | 11111111111111111111111111111111 30 | 31 | 0 32 | INFO 33 | XX-XXXXXXX-XXXX-clientsys Success 34 | 35 | 4 36 | 37 | 20171203121212 38 | USD 39 | 40 | ameritrade.com 41 | 121212121 42 | 43 | 44 | 20171130000000 45 | 20171203000000 46 | 47 | 48 | 49 | 50 | 51 | 023135106 52 | CUSIP 53 | 54 | CASH 55 | LONG 56 | 1 57 | 1000 58 | 1000 59 | 20171203120000 60 | 61 | 62 | 63 | 64 | 65 | 912810RW0 66 | CUSIP 67 | 68 | CASH 69 | LONG 70 | 1000 71 | 100 72 | 1000 73 | 20171203120000 74 | 75 | 76 | 77 | 78 | 0 79 | 0 80 | 0 81 | 0 82 | 83 | 84 | MoneyMarket 85 | MoneyMarket 86 | DOLLAR 87 | 0 88 | 20171203121212 89 | 90 | 91 | LongStock 92 | LongStock 93 | DOLLAR 94 | 1000 95 | 20171203121212 96 | 97 | 98 | LongOption 99 | LongOption 100 | DOLLAR 101 | 0 102 | 20171203121212 103 | 104 | 105 | ShortOption 106 | ShortOption 107 | DOLLAR 108 | 0 109 | 20171203121212 110 | 111 | 112 | MutualFund 113 | MutualFund 114 | DOLLAR 115 | 0 116 | 20171203121212 117 | 118 | 119 | Savings 120 | Savings 121 | DOLLAR 122 | 0 123 | 20171203121212 124 | 125 | 126 | BondValue 127 | BondValue 128 | DOLLAR 129 | 1000 130 | 20171203121212 131 | 132 | 133 | AccountValue 134 | AccountValue 135 | DOLLAR 136 | 2000 137 | 20171203121212 138 | 139 | 140 | PendingDeposits 141 | PendingDeposits 142 | DOLLAR 143 | 0 144 | 20171203121212 145 | 146 | 147 | CashForWithdrawl 148 | CashForWithdrawl 149 | DOLLAR 150 | 0 151 | 20171203121212 152 | 153 | 154 | UnsettledCash 155 | UnsettledCash 156 | DOLLAR 157 | 0 158 | 20171203121212 159 | 160 | 161 | CashDebitCall 162 | CashDebitCall 163 | DOLLAR 164 | 0 165 | 20171203121212 166 | 167 | 168 | AvailableFunds 169 | AvailableFunds 170 | DOLLAR 171 | 0 172 | 20171203121212 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 023135106 185 | CUSIP 186 | 187 | Amazon.com, Inc. - Common Stock 188 | AMZN 189 | 190 | 191 | 192 | 193 | 194 | 912810RW0 195 | CUSIP 196 | 197 | US Treasury 2047 198 | 912810RW0 199 | 200 | 1000 201 | ZERO 202 | 203 | 204 | 205 | 206 | -------------------------------------------------------------------------------- /ofxparse/ofxprinter.py: -------------------------------------------------------------------------------- 1 | import six 2 | 3 | 4 | class OfxPrinter(): 5 | ofx = None 6 | out_filename = None 7 | out_handle = None 8 | term = "\r\n" 9 | 10 | def __init__(self, ofx, filename, term="\r\n"): 11 | self.ofx = ofx 12 | self.out_filename = filename 13 | self.term = term 14 | 15 | def writeLine(self, data, tabs=0, term=None): 16 | if term is None: 17 | term = self.term 18 | 19 | tabbing = (tabs * "\t") if (tabs > 0) else '' 20 | 21 | return self.out_handle.write( 22 | "{0}{1}{2}".format( 23 | tabbing, 24 | data, 25 | term 26 | ) 27 | ) 28 | 29 | def writeHeaders(self): 30 | for k, v in six.iteritems(self.ofx.headers): 31 | if v is None: 32 | self.writeLine("{0}:NONE".format(k)) 33 | else: 34 | self.writeLine("{0}:{1}".format(k, v)) 35 | self.writeLine("") 36 | 37 | def writeSignOn(self, tabs=0): 38 | # signon already has newlines and tabs in it 39 | # TODO: reimplement signon printing with tabs 40 | self.writeLine(self.ofx.signon.__str__(), term="") 41 | 42 | def printDate(self, dt, msec_digs=3): 43 | strdt = dt.strftime('%Y%m%d%H%M%S') 44 | strdt_msec = dt.strftime('%f') 45 | if len(strdt_msec) < msec_digs: 46 | strdt_msec += ('0' * (msec_digs - len(strdt_msec))) 47 | elif len(strdt_msec) > msec_digs: 48 | strdt_msec = strdt_msec[:msec_digs] 49 | return strdt + '.' + strdt_msec 50 | 51 | def writeTrn(self, trn, tabs=5): 52 | self.writeLine("", tabs=tabs) 53 | tabs += 1 54 | 55 | self.writeLine("{}".format(trn.type.upper()), tabs=tabs) 56 | self.writeLine("{}".format( 57 | self.printDate(trn.date) 58 | ), tabs=tabs) 59 | self.writeLine("{0:.2f}".format(float(trn.amount)), tabs=tabs) 60 | 61 | self.writeLine("{}".format(trn.id), tabs=tabs) 62 | 63 | if len(str(trn.checknum)) > 0: 64 | self.writeLine("{}".format( 65 | trn.checknum 66 | ), tabs=tabs) 67 | 68 | self.writeLine("{}".format(trn.payee), tabs=tabs) 69 | 70 | if len(trn.memo.strip()) > 0: 71 | self.writeLine("{}".format(trn.memo), tabs=tabs) 72 | 73 | tabs -= 1 74 | self.writeLine("", tabs=tabs) 75 | 76 | def writeLedgerBal(self, statement, tabs=4): 77 | bal = getattr(statement, 'balance', None) 78 | baldt = getattr(statement, 'balance_date', None) 79 | 80 | if bal and baldt: 81 | self.writeLine("", tabs=tabs) 82 | self.writeLine("{0:.2f}".format(float(bal)), tabs=tabs+1) 83 | self.writeLine("{0}".format( 84 | self.printDate(baldt) 85 | ), tabs=tabs+1) 86 | self.writeLine("", tabs=tabs) 87 | 88 | def writeAvailBal(self, statement, tabs=4): 89 | bal = getattr(statement, 'available_balance', None) 90 | baldt = getattr(statement, 'available_balance_date', None) 91 | 92 | if bal and baldt: 93 | self.writeLine("", tabs=tabs) 94 | self.writeLine("{0:.2f}".format(float(bal)), tabs=tabs+1) 95 | self.writeLine("{0}".format( 96 | self.printDate(baldt) 97 | ), tabs=tabs+1) 98 | self.writeLine("", tabs=tabs) 99 | 100 | def writeStmTrs(self, tabs=3): 101 | for acct in self.ofx.accounts: 102 | self.writeLine("", tabs=tabs) 103 | tabs += 1 104 | 105 | if acct.curdef: 106 | self.writeLine("{0}".format( 107 | acct.curdef 108 | ), tabs=tabs) 109 | 110 | if acct.routing_number or acct.account_id or acct.account_type: 111 | self.writeLine("", tabs=tabs) 112 | if acct.routing_number: 113 | self.writeLine("{0}".format( 114 | acct.routing_number 115 | ), tabs=tabs+1) 116 | if acct.account_id: 117 | self.writeLine("{0}".format( 118 | acct.account_id 119 | ), tabs=tabs+1) 120 | if acct.account_type: 121 | self.writeLine("{0}".format( 122 | acct.account_type 123 | ), tabs=tabs+1) 124 | self.writeLine("", tabs=tabs) 125 | 126 | self.writeLine("", tabs=tabs) 127 | tabs += 1 128 | self.writeLine("{0}".format( 129 | self.printDate(acct.statement.start_date) 130 | ), tabs=tabs) 131 | self.writeLine("{0}".format( 132 | self.printDate(acct.statement.end_date) 133 | ), tabs=tabs) 134 | 135 | for trn in acct.statement.transactions: 136 | self.writeTrn(trn, tabs=tabs) 137 | 138 | tabs -= 1 139 | 140 | self.writeLine("", tabs=tabs) 141 | 142 | self.writeLedgerBal(acct.statement, tabs=tabs) 143 | self.writeAvailBal(acct.statement, tabs=tabs) 144 | 145 | tabs -= 1 146 | 147 | self.writeLine("", tabs=tabs) 148 | 149 | def writeBankMsgsRsv1(self, tabs=1): 150 | self.writeLine("", tabs=tabs) 151 | tabs += 1 152 | self.writeLine("", tabs=tabs) 153 | tabs += 1 154 | if self.ofx.trnuid is not None: 155 | self.writeLine("{0}".format( 156 | self.ofx.trnuid 157 | ), tabs=tabs) 158 | if self.ofx.status: 159 | self.writeLine("", tabs=tabs) 160 | self.writeLine("{0}".format( 161 | self.ofx.status['code'] 162 | ), tabs=tabs+1) 163 | self.writeLine("{0}".format( 164 | self.ofx.status['severity'] 165 | ), tabs=tabs+1) 166 | self.writeLine("", tabs=tabs) 167 | self.writeStmTrs(tabs=tabs) 168 | tabs -= 1 169 | self.writeLine("", tabs=tabs) 170 | tabs -= 1 171 | self.writeLine("", tabs=tabs) 172 | 173 | def writeOfx(self, tabs=0): 174 | self.writeLine("", tabs=tabs) 175 | tabs += 1 176 | self.writeSignOn(tabs=tabs) 177 | self.writeBankMsgsRsv1(tabs=tabs) 178 | tabs -= 1 179 | # No newline at end of file 180 | self.writeLine("", tabs=tabs, term="") 181 | 182 | def writeToFile(self, fileObject, tabs=0): 183 | if self.out_handle: 184 | raise Exception("Already writing file") 185 | 186 | self.out_handle = fileObject 187 | 188 | self.writeHeaders() 189 | 190 | self.writeOfx(tabs=tabs) 191 | 192 | self.out_handle.flush() 193 | self.out_handle = None 194 | 195 | def write(self, filename=None, tabs=0): 196 | if filename is None: 197 | filename = self.out_filename 198 | 199 | with open(filename, 'w') as f: 200 | self.writeToFile(f) 201 | -------------------------------------------------------------------------------- /ofxparse/ofxutil.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, with_statement 2 | 3 | import os 4 | import collections 5 | import xml.etree.ElementTree as ET 6 | import six 7 | 8 | if 'OrderedDict' in dir(collections): 9 | odict = collections 10 | else: 11 | import ordereddict as odict 12 | 13 | 14 | class InvalidOFXStructureException(Exception): 15 | pass 16 | 17 | 18 | class OfxData(object): 19 | def __init__(self, tag): 20 | self.nodes = odict.OrderedDict() 21 | self.tag = tag 22 | self.data = "" 23 | 24 | def add_tag(self, name): 25 | name = name.lower() 26 | if name not in self.nodes: 27 | self.nodes[name] = OfxData(name.upper()) 28 | return self.nodes[name] 29 | elif not isinstance(self.nodes[name], list): 30 | self.nodes[name] = [self.nodes[name], OfxData(name.upper())] 31 | else: 32 | self.nodes[name].append(OfxData(name.upper())) 33 | return self.nodes[name][-1] 34 | 35 | def del_tag(self, name): 36 | name = name.lower() 37 | if name in self.nodes: 38 | del self.nodes[name] 39 | 40 | def __setattr__(self, name, value): 41 | if name in self.__dict__ or name in ['nodes', 'tag', 'data', '\ 42 | headers', 'xml']: 43 | self.__dict__[name] = value 44 | else: 45 | self.del_tag(name) 46 | if isinstance(value, list): 47 | for val in value: 48 | tag = self.add_tag(name) 49 | tag.nodes = val.nodes 50 | tag.data = val.data 51 | elif isinstance(value, OfxData): 52 | tag = self.add_tag(name) 53 | tag.nodes = value.nodes 54 | tag.data = value.data 55 | else: 56 | tag = self.add_tag(name) 57 | tag.data = str(value) 58 | 59 | def __getattr__(self, name): 60 | if name in self.__dict__: 61 | return self.__dict__[name] 62 | elif name in self.__dict__['nodes']: 63 | return self.__dict__['nodes'][name] 64 | else: 65 | tag = self.add_tag(name) 66 | return tag 67 | 68 | def __delattr__(self, name): 69 | if name in self.__dict__: 70 | del self.__dict__[name] 71 | elif name in self.__dict__['nodes']: 72 | del self.__dict__['nodes'][name] 73 | else: 74 | raise AttributeError 75 | 76 | def __getitem__(self, name): 77 | item_list = [] 78 | self.find(name, item_list) 79 | return item_list 80 | 81 | def find(self, name, item_list): 82 | for n, child in six.iteritems(self.nodes): 83 | if isinstance(child, OfxData): 84 | if child.tag.lower() == name: 85 | item_list.append(child) 86 | child.find(name, item_list) 87 | else: 88 | for grandchild in child: 89 | if grandchild.tag.lower() == name: 90 | item_list.append(grandchild) 91 | grandchild.find(name, item_list) 92 | 93 | def __iter__(self): 94 | for k, v in six.iteritems(self.nodes): 95 | yield v 96 | 97 | def __contains__(self, item): 98 | return item in self.nodes 99 | 100 | def __len__(self): 101 | return len(self.nodes) 102 | 103 | def __str__(self): 104 | return os.linesep.join("\t" * line[1] + line[0] for line in 105 | self.format()) 106 | 107 | def format(self): 108 | if self.data or not self.nodes: 109 | if self.tag.upper() == "OFX": 110 | return [["<%s>%s" % (self.tag, self.data 111 | if self.data else "", self.tag), 0]] 112 | return [["<%s>%s" % (self.tag, self.data), 0]] 113 | else: 114 | ret = [["<%s>" % self.tag, -1]] 115 | for _, child in six.iteritems(self.nodes): 116 | if isinstance(child, OfxData): 117 | ret.extend(child.format()) 118 | else: 119 | for grandchild in child: 120 | ret.extend(grandchild.format()) 121 | ret.append(["" % self.tag, -1]) 122 | 123 | for item in ret: 124 | item[1] += 1 125 | 126 | return ret 127 | 128 | 129 | class OfxUtil(OfxData): 130 | def __init__(self, ofx_data=None): 131 | super(OfxUtil, self).__init__('OFX') 132 | self.headers = odict.OrderedDict() 133 | self.xml = "" 134 | if ofx_data: 135 | if isinstance(ofx_data, six.string_types) and not \ 136 | ofx_data.lower().endswith('.ofx'): 137 | self.parse(ofx_data) 138 | else: 139 | self.parse(open(ofx_data).read() if isinstance( 140 | ofx_data, six.string_types) else ofx_data.read()) 141 | 142 | def parse(self, ofx): 143 | try: 144 | for line in ofx.splitlines(): 145 | if line.strip() == "": 146 | break 147 | header, value = line.split(":") 148 | self.headers[header] = value 149 | except ValueError: 150 | pass 151 | finally: 152 | if "OFXHEADER" not in self.headers: 153 | self.headers["OFXHEADER"] = "100" 154 | if "VERSION" not in self.headers: 155 | self.headers["VERSION"] = "102" 156 | if "SECURITY" not in self.headers: 157 | self.headers["SECURITY"] = "NONE" 158 | if "OLDFILEUID" not in self.headers: 159 | self.headers["OLDFILEUID"] = "NONE" 160 | if "NEWFILEUID" not in self.headers: 161 | self.headers["NEWFILEUID"] = "NONE" 162 | 163 | try: 164 | tags = ofx.split("<") 165 | if len(tags) > 1: 166 | tags = ["<" + t.strip() for t in tags[1:]] 167 | 168 | heirarchy = [] 169 | can_open = True 170 | 171 | for i, tag in enumerate(tags): 172 | gt = tag.index(">") 173 | if tag[1] != "/": 174 | # Is an opening tag 175 | if not can_open: 176 | tags[i - 1] = tags[i - 1] + "" 178 | can_open = True 179 | tag_name = tag[1:gt].split()[0] 180 | heirarchy.append(tag_name) 181 | if len(tag) > gt + 1: 182 | can_open = False 183 | else: 184 | # Is a closing tag 185 | tag_name = tag[2:gt].split()[0] 186 | if tag_name not in heirarchy: 187 | # Close tag with no matching open, so delete it 188 | tags[i] = tag[gt + 1:] 189 | else: 190 | # Close tag with matching open, but other open 191 | # tags that need to be closed first 192 | while(tag_name != heirarchy[-1]): 193 | tags[i - 1] = tags[i - 1] + "" 195 | can_open = True 196 | heirarchy.pop() 197 | 198 | self.xml = ET.fromstringlist(tags) 199 | self.load_from_xml(self, self.xml) 200 | except Exception: 201 | raise InvalidOFXStructureException 202 | 203 | def load_from_xml(self, ofx, xml): 204 | ofx.data = xml.text 205 | for child in xml: 206 | tag = ofx.add_tag(child.tag) 207 | self.load_from_xml(tag, child) 208 | 209 | def reload_xml(self): 210 | super(OfxUtil, self).__init__('OFX') 211 | self.load_from_xml(self, self.xml) 212 | 213 | def write(self, output_file): 214 | with open(output_file, 'wb') as f: 215 | f.write(str(self)) 216 | 217 | def __str__(self): 218 | ret = os.linesep.join(":".join(line) for line in 219 | six.iteritems(self.headers)) + os.linesep * 2 220 | ret += super(OfxUtil, self).__str__() 221 | return ret 222 | 223 | 224 | if __name__ == "__main__": 225 | here = os.path.dirname(__file__) 226 | fixtures = os.path.join(here, '../tests/fixtures/') 227 | ofx = OfxUtil(fixtures + 'checking.ofx') 228 | # ofx = OfxUtil(fixtures + 'fidelity.ofx') 229 | 230 | # Manipulate OFX file via XML library 231 | # for transaction in ofx.xml.iter('STMTTRN'): 232 | # transaction.find('NAME').text = transaction.find('MEMO').text 233 | # transaction.remove(transaction.find('MEMO')) 234 | # ofx.reload_xml() 235 | 236 | # Manipulate OFX file via object tree built from XML 237 | # for transaction in ofx.bankmsgsrsv1.stmttrnrs.stmtrs.banktranlist.stmttrn: 238 | # transaction.name = transaction.memo 239 | # del transaction.memo 240 | # transaction.notes = "Acknowledged" 241 | # Modified sytnax for object tree data manipulation 242 | # I'm using the __getitem__ method like the xml.iter method from 243 | # ElementTree, as a recursive search 244 | for transaction in ofx['stmttrn']: 245 | transaction.name = transaction.memo 246 | del transaction.memo 247 | transaction.notes = "Acknowledged" 248 | 249 | # for bal in ofx['bal']: 250 | # print(bal) 251 | 252 | # ofx.test = "First assignment operation" 253 | # ofx.test = "Second assignment operation" 254 | # 255 | print(ofx) 256 | 257 | # Write OFX data to output file 258 | # ofx.write('out.ofx') 259 | 260 | # for file_name in os.listdir(fixtures): 261 | # if os.path.isfile(fixtures + file_name): 262 | # print "Attempting to parse", file_name 263 | # ofx = OfxParser('fixtures/' + file_name) 264 | # 265 | # file_parts = file_name.split(".") 266 | # file_parts.insert(1, 'v2') 267 | # with open('fixtures/' + ".".join(file_parts), 'wb') as f: 268 | # f.write(str(ofx)) 269 | -------------------------------------------------------------------------------- /tests/fixtures/fidelity.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 10 | 11 | 0INFOSUCCESS20120908190849.317[-4:EDT]ENGfidelity.com7776a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a00INFOSUCCESS20120908033034.000[-4:EDT]USDfidelity.com0123456789020120710000000.000[-4:EDT]20120908190849.555[-4:EDT]012345678902020112012072020120720000000.000[-4:EDT]YOU BOUGHT458140100CUSIP+0000000000100.00000000000025.635000000+00000000000007.9500+00000000000000.0000-00000000002571.45001.00USDCASHCASHBUY 012345678902090112012072720120727000000.000[-4:EDT]YOU BOUGHTG7945E105CUSIP+0000000000128.00000000000039.390900000+00000000000007.9500+00000000000000.0000-00000000005049.99001.00USDCASHCASHBUY 012345678902090122012072720120727000000.000[-4:EDT]YOU BOUGHT431571108CUSIP+0000000000115.00000000000017.250000000+00000000000007.9500+00000000000000.0000-00000000001991.70001.00USDCASHCASHBUY 012345678902130112012073120120731000000.000[-4:EDT]YOU BOUGHT19421R200CUSIP+0000000000069.00000000000014.469900000+00000000000007.9500+00000000000000.0000-00000000001006.37001.00USDCASHCASHBUY 012345678902130162012073120120731000000.000[-4:EDT]YOU BOUGHT98417P105CUSIP+0000000000386.00000000000002.588700000+00000000000007.9500+00000000000000.0000-00000000001007.19001.00USDCASHCASHBUY 012345678902350122012082020120820000000.000[-4:EDT]REINVESTMENT98417P105CUSIP+0000000000004.90900000000002.947400000+00000000000000.0000+00000000000000.0000-00000000000014.47001.00USDCASHCASHBUY 012345678902440112012083120120831000000.000[-4:EDT]REINVESTMENT19421R200CUSIP+0000000000001.57300000000014.257000000+00000000000000.0000+00000000000000.0000-00000000000022.43001.00USDCASHCASHBUY 012345678902480112012090120120901000000.000[-4:EDT]REINVESTMENT458140100CUSIP+0000000000000.91100000000024.705500000+00000000000000.0000+00000000000000.0000-00000000000022.50001.00USDCASHCASHBUY 012345678902130152012073120120731000000.000[-4:EDT]DIVIDEND RECEIVED78462F103CUSIPDIV+00000000000005.5300CASHCASH1.00USD 012345678902350132012082020120820000000.000[-4:EDT]DIVIDEND RECEIVED98417P105CUSIPDIV+00000000000015.4400CASHCASH1.00USD 012345678902440122012083120120831000000.000[-4:EDT]DIVIDEND RECEIVED19421R200CUSIPDIV+00000000000022.4300CASHCASH1.00USD 012345678902480122012090120120901000000.000[-4:EDT]DIVIDEND RECEIVED458140100CUSIPDIV+00000000000022.5000CASHCASH1.00USD 012345678902090132012072720120727000000.000[-4:EDT]YOU SOLD78462F103CUSIP-0000000000008.00000000000137.160000000+00000000000007.9500+00000000000000.0000+00000000001089.30001.00USDCASHCASHSELL 012345678902140142012080120120801000000.000[-4:EDT]IN LIEU OF FRX SHARE78462F103CUSIP-0000000000000.03500000000137.142857143+00000000000000.0000+00000000000000.0000+00000000000004.80001.00USDCASHCASHSELL DEP20120731000000.000[-4:EDT]+00000000000000.24000123456789021301320120731INTEREST EARNEDINTEREST EARNED1.00USD CASH OTHER20120820000000.000[-4:EDT]-00000000000000.97000123456789023501120120820LATE SETTLEMENT FEELATE SETTLEMENT FEE1.00USD CASH DEP20120831000000.000[-4:EDT]+00000000000000.16000123456789024401420120831INTEREST EARNEDINTEREST EARNED1.00USD CASH G7945E105CUSIPCASHLONG128.0000040.8700000+00000005231.3620120908033034.000[-4:EDT]1.0USD19421R200CUSIPCASHLONG70.5730014.3200000+00000001010.6020120908033034.000[-4:EDT]1.0USD431571108CUSIPCASHLONG115.0000018.9300000+00000002176.9520120908033034.000[-4:EDT]1.0USD458140100CUSIPCASHLONG100.9110024.1900000+00000002441.0320120908033034.000[-4:EDT]1.0USD756577102CUSIPCASHLONG50.0000059.1500000+00000002957.5020120908033034.000[-4:EDT]1.0USD98417P105CUSIPCASHLONG390.909002.8200000+00000001102.3620120908033034.000[-4:EDT]1.0USD18073.98+00000000000.00+00000000000.00+00000000000.00NetworthThe net market value of all long and short positions in the accountDOLLAR32993.7920120908033034.000[-4:EDT]1.000USDMargin EquityThe margin market value less any margin debit balanceDOLLAR0.020120908033034.000[-4:EDT]1.000USDMargin Equity PercentageMargin equity / market value of long and short positionsPERCENT0.020120908033034.000[-4:EDT]1.000USDCash Debit BalanceCash Debit BalanceDOLLAR0.020120908033034.000[-4:EDT]1.000USDTotal Money MarketsThe total value of all money market positions in the cash accountDOLLAR18073.9820120908033034.000[-4:EDT]1.000USDHouse SurplusEquity amount above house requirementsDOLLAR0.020120908033034.000[-4:EDT]1.000USDNYSE SurplusEquity amount above exchange requirementsDOLLAR0.020120908033034.000[-4:EDT]1.000USDFederal SurplusAmount above federal requirementsDOLLAR0.020120908033034.000[-4:EDT]1.000USDBuying Power - EquitiesAmount of equities you can buy on margin without generating a margin callDOLLAR0.020120908033034.000[-4:EDT]1.000USDBuying Power - Municipal BondsAmount of municipal bonds you can buy on margin without generating a margin callDOLLAR0.020120908033034.000[-4:EDT]1.000USDBuying Power - Government BondsAmount of government bonds you can buy on margin without generating a margin calDOLLAR0.020120908033034.000[-4:EDT]1.000USDBuying Power - Corporate BondsAmount you can buy of corporate bonds on margin with no margin callDOLLAR0.020120908033034.000[-4:EDT]1.000USDOption Market ValueThe market value of all options in the accountDOLLAR0.020120908033034.000[-4:EDT]1.000USDOption In The Money AmountThe in-the-money amount on covered optionsDOLLAR0.020120908033034.000[-4:EDT]1.000USDCash Market valueTotal value of all cash account positionsDOLLAR14919.820120908033034.000[-4:EDT]1.000USDMargin Market ValueTotal value of positions in margin less in-the-money amount of covered optionsDOLLAR0.020120908033034.000[-4:EDT]1.000USDShort Market ValueTotal value of short positions less in-the-money amount of covered put optionsDOLLAR0.020120908033034.000[-4:EDT]1.000USDAvailable to BorrowCash amount that can be borrowed without generating a margin callDOLLAR0.020120908033034.000[-4:EDT]1.000USDG7945E105CUSIPSEADRILL LTD USD2SDRL20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]19421R200CUSIPCOLLECTORS UNIVERSE INCCLCT20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]431571108CUSIPHILLENBRAND INC COMHI20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]458140100CUSIPINTEL CORPINTC20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]756577102CUSIPRED HAT INCRHT20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]98417P105CUSIPXINYUAN REAL ESTATE ADR EACH REPR 2 ORD SHSXIN20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]78462F103CUSIPSPDR S&P 500 ETF TRUST UNIT SER 1 S&PSPY20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT] 12 | -------------------------------------------------------------------------------- /tests/test_parse.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import os 4 | from datetime import datetime, timedelta 5 | from decimal import Decimal 6 | from unittest import TestCase 7 | import sys 8 | sys.path.insert(0, os.path.abspath('..')) 9 | 10 | import six 11 | 12 | from .support import open_file 13 | from ofxparse import OfxParser, AccountType, Account, Statement, Transaction 14 | from ofxparse.ofxparse import OfxFile, OfxPreprocessedFile, OfxParserException, soup_maker 15 | 16 | 17 | class TestOfxFile(TestCase): 18 | OfxFileCls = OfxFile 19 | 20 | def assertHeadersTypes(self, headers): 21 | """ 22 | Assert that the headers keys and values have the correct types 23 | 24 | :param headers: headers dict from a OfxFile or OfxPreprocessedFile instance 25 | """ 26 | for key, value in six.iteritems(headers): 27 | self.assertTrue(type(key) is six.text_type) 28 | self.assertTrue(type(value) is not six.binary_type) 29 | 30 | def testHeaders(self): 31 | expect = {"OFXHEADER": six.u("100"), 32 | "DATA": six.u("OFXSGML"), 33 | "VERSION": six.u("102"), 34 | "SECURITY": None, 35 | "ENCODING": six.u("USASCII"), 36 | "CHARSET": six.u("1252"), 37 | "COMPRESSION": None, 38 | "OLDFILEUID": None, 39 | "NEWFILEUID": None, 40 | } 41 | with open_file('bank_medium.ofx') as f: 42 | ofx = OfxParser.parse(f) 43 | self.assertEqual(expect, ofx.headers) 44 | 45 | def testTextFileHandler(self): 46 | with open_file("bank_medium.ofx") as fh: 47 | with open_file("bank_medium.ofx", mode="r") as fh_str: 48 | ofx_file = self.OfxFileCls(fh) 49 | headers = ofx_file.headers 50 | data = ofx_file.fh.read() 51 | 52 | self.assertTrue(type(data) is six.text_type) 53 | self.assertHeadersTypes(headers) 54 | 55 | ofx_file = self.OfxFileCls(fh_str) 56 | headers = ofx_file.headers 57 | data = ofx_file.fh.read() 58 | 59 | self.assertTrue(type(data) is six.text_type) 60 | self.assertHeadersTypes(headers) 61 | 62 | def testTextStartsWithTag(self): 63 | with open_file('anzcc.ofx', mode='r') as f: 64 | ofx = OfxParser.parse(f) 65 | self.assertEqual(ofx.account.number, '1234123412341234') 66 | 67 | def testUTF8(self): 68 | fh = six.BytesIO(six.b("""OFXHEADER:100 69 | DATA:OFXSGML 70 | VERSION:102 71 | SECURITY:NONE 72 | ENCODING:UNICODE 73 | COMPRESSION:NONE 74 | OLDFILEUID:NONE 75 | NEWFILEUID:NONE 76 | 77 | """)) 78 | ofx_file = self.OfxFileCls(fh) 79 | headers = ofx_file.headers 80 | data = ofx_file.fh.read() 81 | 82 | self.assertTrue(type(data) is six.text_type) 83 | self.assertHeadersTypes(headers) 84 | 85 | def testCP1252(self): 86 | fh = six.BytesIO(six.b("""OFXHEADER:100 87 | DATA:OFXSGML 88 | VERSION:102 89 | SECURITY:NONE 90 | ENCODING:USASCII 91 | CHARSET: 1252 92 | COMPRESSION:NONE 93 | OLDFILEUID:NONE 94 | NEWFILEUID:NONE 95 | """)) 96 | ofx_file = self.OfxFileCls(fh) 97 | headers = ofx_file.headers 98 | result = ofx_file.fh.read() 99 | 100 | self.assertTrue(type(result) is six.text_type) 101 | self.assertHeadersTypes(headers) 102 | 103 | def testUTF8Japanese(self): 104 | fh = six.BytesIO(six.b("""OFXHEADER:100 105 | DATA:OFXSGML 106 | VERSION:102 107 | SECURITY:NONE 108 | ENCODING:UTF-8 109 | CHARSET:CSUNICODE 110 | COMPRESSION:NONE 111 | OLDFILEUID:NONE 112 | NEWFILEUID:NONE 113 | """)) 114 | ofx_file = self.OfxFileCls(fh) 115 | headers = ofx_file.headers 116 | result = ofx_file.fh.read() 117 | 118 | self.assertTrue(type(result) is six.text_type) 119 | self.assertHeadersTypes(headers) 120 | 121 | def testBrokenLineEndings(self): 122 | fh = six.BytesIO(six.b("OFXHEADER:100\rDATA:OFXSGML\r")) 123 | ofx_file = self.OfxFileCls(fh) 124 | self.assertEqual(len(ofx_file.headers.keys()), 2) 125 | 126 | 127 | class TestOfxPreprocessedFile(TestOfxFile): 128 | OfxFileCls = OfxPreprocessedFile 129 | 130 | def testPreprocess(self): 131 | fh = six.BytesIO(six.b("""OFXHEADER:100 132 | DATA:OFXSGML 133 | VERSION:102 134 | SECURITY:NONE 135 | ENCODING:USASCII 136 | CHARSET:1252 137 | COMPRESSION:NONE 138 | OLDFILEUID:NONE 139 | NEWFILEUID:NONE 140 | 141 | abNet2222Gross3333 142 | """)) 143 | expect = """OFXHEADER:100 144 | DATA:OFXSGML 145 | VERSION:102 146 | SECURITY:NONE 147 | ENCODING:USASCII 148 | CHARSET:1252 149 | COMPRESSION:NONE 150 | OLDFILEUID:NONE 151 | NEWFILEUID:NONE 152 | 153 | abNet2222Gross3333 154 | """ 155 | ofx_file = OfxPreprocessedFile(fh) 156 | data = ofx_file.fh.read() 157 | self.assertEqual(data, expect) 158 | 159 | 160 | class TestParse(TestCase): 161 | def testEmptyFile(self): 162 | fh = six.BytesIO(six.b("")) 163 | self.assertRaises(OfxParserException, OfxParser.parse, fh) 164 | 165 | def testThatParseWorksWithoutErrors(self): 166 | with open_file('bank_medium.ofx') as f: 167 | OfxParser.parse(f) 168 | 169 | def testThatParseFailsIfNothingToParse(self): 170 | self.assertRaises(TypeError, OfxParser.parse, None) 171 | 172 | def testThatParseFailsIfAPathIsPassedIn(self): 173 | # A file handle should be passed in, not the path. 174 | self.assertRaises(TypeError, OfxParser.parse, '/foo/bar') 175 | 176 | def testThatParseReturnsAResultWithABankAccount(self): 177 | with open_file('bank_medium.ofx') as f: 178 | ofx = OfxParser.parse(f) 179 | self.assertTrue(ofx.account is not None) 180 | 181 | def testEverything(self): 182 | with open_file('bank_medium.ofx') as f: 183 | ofx = OfxParser.parse(f) 184 | self.assertEqual('12300 000012345678', ofx.account.number) 185 | self.assertEqual('160000100', ofx.account.routing_number) 186 | self.assertEqual('00', ofx.account.branch_id) 187 | self.assertEqual('CHECKING', ofx.account.account_type) 188 | self.assertEqual(Decimal('382.34'), ofx.account.statement.balance) 189 | self.assertEqual(datetime(2009, 5, 23, 12, 20, 17), 190 | ofx.account.statement.balance_date) 191 | # Todo: support values in decimal or int form. 192 | # self.assertEqual('15', 193 | # ofx.bank_account.statement.balance_in_pennies) 194 | self.assertEqual( 195 | Decimal('682.34'), ofx.account.statement.available_balance) 196 | self.assertEqual(datetime(2009, 5, 23, 12, 20, 17), 197 | ofx.account.statement.available_balance_date) 198 | self.assertEqual( 199 | datetime(2009, 4, 1), ofx.account.statement.start_date) 200 | self.assertEqual( 201 | datetime(2009, 5, 23, 12, 20, 17), ofx.account.statement.end_date) 202 | 203 | self.assertEqual(3, len(ofx.account.statement.transactions)) 204 | 205 | transaction = ofx.account.statement.transactions[0] 206 | self.assertEqual("MCDONALD'S #112", transaction.payee) 207 | self.assertEqual('pos', transaction.type) 208 | self.assertEqual(Decimal('-6.60'), transaction.amount) 209 | # Todo: support values in decimal or int form. 210 | # self.assertEqual('15', transaction.amount_in_pennies) 211 | 212 | def testMultipleAccounts(self): 213 | with open_file('multiple_accounts2.ofx') as f: 214 | ofx = OfxParser.parse(f) 215 | self.assertEqual(2, len(ofx.accounts)) 216 | self.assertEqual('9100', ofx.accounts[0].number) 217 | self.assertEqual('9200', ofx.accounts[1].number) 218 | self.assertEqual('123', ofx.accounts[0].routing_number) 219 | self.assertEqual('123', ofx.accounts[1].routing_number) 220 | self.assertEqual('CHECKING', ofx.accounts[0].account_type) 221 | self.assertEqual('SAVINGS', ofx.accounts[1].account_type) 222 | 223 | 224 | class TestStringToDate(TestCase): 225 | ''' Test the string to date parser ''' 226 | def test_bad_format(self): 227 | ''' A poorly formatted string should throw a ValueError ''' 228 | 229 | bad_string = 'abcdLOL!' 230 | self.assertRaises(ValueError, OfxParser.parseOfxDateTime, bad_string) 231 | 232 | bad_but_close_string = '881103' 233 | self.assertRaises(ValueError, OfxParser.parseOfxDateTime, bad_but_close_string) 234 | 235 | no_month_string = '19881301' 236 | self.assertRaises(ValueError, OfxParser.parseOfxDateTime, no_month_string) 237 | 238 | def test_returns_none(self): 239 | self.assertIsNone(OfxParser.parseOfxDateTime('00000000')) 240 | 241 | def test_parses_correct_time(self): 242 | '''Test whether it can parse correct time for some valid time fields''' 243 | self.assertEqual(OfxParser.parseOfxDateTime('19881201'), 244 | datetime(1988, 12, 1, 0, 0)) 245 | self.assertEqual(OfxParser.parseOfxDateTime('19881201230100'), 246 | datetime(1988, 12, 1, 23, 1)) 247 | self.assertEqual(OfxParser.parseOfxDateTime('20120229230100'), 248 | datetime(2012, 2, 29, 23, 1)) 249 | 250 | def test_parses_time_offset(self): 251 | ''' Test that we handle GMT offset ''' 252 | self.assertEqual(OfxParser.parseOfxDateTime('20001201120000 [0:GMT]'), 253 | datetime(2000, 12, 1, 12, 0)) 254 | self.assertEqual(OfxParser.parseOfxDateTime('19991201120000 [1:ITT]'), 255 | datetime(1999, 12, 1, 11, 0)) 256 | self.assertEqual( 257 | OfxParser.parseOfxDateTime('19881201230100 [-5:EST]'), 258 | datetime(1988, 12, 2, 4, 1)) 259 | self.assertEqual( 260 | OfxParser.parseOfxDateTime('20120229230100 [-6:CAT]'), 261 | datetime(2012, 3, 1, 5, 1)) 262 | self.assertEqual( 263 | OfxParser.parseOfxDateTime('20120412120000 [-5.5:XXX]'), 264 | datetime(2012, 4, 12, 17, 30)) 265 | self.assertEqual( 266 | OfxParser.parseOfxDateTime('20120412120000 [-5:XXX]'), 267 | datetime(2012, 4, 12, 17)) 268 | self.assertEqual( 269 | OfxParser.parseOfxDateTime('20120922230000 [+9:JST]'), 270 | datetime(2012, 9, 22, 14, 0)) 271 | 272 | 273 | class TestParseStmtrs(TestCase): 274 | input = ''' 275 | CAD16000010012300 000012345678CHECKING 276 | 2009040120090523122017 277 | POS20090401122017.000[-5:EST]-6.600000123456782009040100001MCDONALD'S #112POS MERCHANDISE;MCDONALD'S #112 278 | 382.3420090523122017682.3420090523122017 279 | ''' 280 | 281 | def testThatParseStmtrsReturnsAnAccount(self): 282 | stmtrs = soup_maker(self.input) 283 | account = OfxParser.parseStmtrs( 284 | stmtrs.find('stmtrs'), AccountType.Bank)[0] 285 | self.assertEqual('12300 000012345678', account.number) 286 | self.assertEqual('160000100', account.routing_number) 287 | self.assertEqual('CHECKING', account.account_type) 288 | 289 | def testThatReturnedAccountAlsoHasAStatement(self): 290 | stmtrs = soup_maker(self.input) 291 | account = OfxParser.parseStmtrs( 292 | stmtrs.find('stmtrs'), AccountType.Bank)[0] 293 | self.assertTrue(hasattr(account, 'statement')) 294 | 295 | 296 | class TestAccount(TestCase): 297 | def testThatANewAccountIsValid(self): 298 | account = Account() 299 | self.assertEqual('', account.number) 300 | self.assertEqual('', account.routing_number) 301 | self.assertEqual('', account.account_type) 302 | self.assertEqual(None, account.statement) 303 | 304 | 305 | class TestParseStatement(TestCase): 306 | def testThatParseStatementReturnsAStatement(self): 307 | input = ''' 308 | 309 | 20090523122017 310 | 311 | 0 312 | INFO 313 | OK 314 | 315 | 316 | CAD 317 | 318 | 160000100 319 | 12300 000012345678 320 | CHECKING 321 | 322 | 323 | 20090401 324 | 20090523122017 325 | 326 | POS 327 | 20090401122017.000[-5:EST] 328 | -6.60 329 | 0000123456782009040100001 330 | MCDONALD'S #112 331 | POS MERCHANDISE;MCDONALD'S #112 332 | 333 | 334 | 335 | 382.34 336 | 20090523122017 337 | 338 | 339 | 682.34 340 | 20090523122017 341 | 342 | 343 | 344 | ''' 345 | txn = soup_maker(input) 346 | statement = OfxParser.parseStatement(txn.find('stmttrnrs')) 347 | self.assertEqual(datetime(2009, 4, 1), statement.start_date) 348 | self.assertEqual( 349 | datetime(2009, 5, 23, 12, 20, 17), statement.end_date) 350 | self.assertEqual(1, len(statement.transactions)) 351 | self.assertEqual(Decimal('382.34'), statement.balance) 352 | self.assertEqual(datetime(2009, 5, 23, 12, 20, 17), statement.balance_date) 353 | self.assertEqual(Decimal('682.34'), statement.available_balance) 354 | self.assertEqual(datetime(2009, 5, 23, 12, 20, 17), statement.available_balance_date) 355 | 356 | def testThatParseStatementWithBlankDatesReturnsAStatement(self): 357 | input = ''' 358 | 359 | 20090523122017 360 | 361 | 0 362 | INFO 363 | OK 364 | 365 | 366 | CAD 367 | 368 | 160000100 369 | 12300 000012345678 370 | CHECKING 371 | 372 | 373 | 00000000 374 | 00000000 375 | 376 | POS 377 | 20090401122017.000[-5:EST] 378 | -6.60 379 | 0000123456782009040100001 380 | MCDONALD'S #112 381 | POS MERCHANDISE;MCDONALD'S #112 382 | 383 | 384 | 385 | 382.34 386 | 20090523122017 387 | 388 | 389 | 682.34 390 | 20090523122017 391 | 392 | 393 | 394 | ''' 395 | txn = soup_maker(input) 396 | statement = OfxParser.parseStatement(txn.find('stmttrnrs')) 397 | self.assertEqual(None, statement.start_date) 398 | self.assertEqual(None, statement.end_date) 399 | self.assertEqual(1, len(statement.transactions)) 400 | self.assertEqual(Decimal('382.34'), statement.balance) 401 | self.assertEqual(datetime(2009, 5, 23, 12, 20, 17), statement.balance_date) 402 | self.assertEqual(Decimal('682.34'), statement.available_balance) 403 | self.assertEqual(datetime(2009, 5, 23, 12, 20, 17), statement.available_balance_date) 404 | 405 | class TestStatement(TestCase): 406 | def testThatANewStatementIsValid(self): 407 | statement = Statement() 408 | self.assertEqual('', statement.start_date) 409 | self.assertEqual('', statement.end_date) 410 | self.assertEqual(0, len(statement.transactions)) 411 | 412 | 413 | class TestParseTransaction(TestCase): 414 | def testThatParseTransactionReturnsATransaction(self): 415 | input = ''' 416 | 417 | POS 418 | 20090131 419 | 20090401122017.000[-5:EST] 420 | -6.60 421 | 0000123456782009040100001 422 | MCDONALD'S #112 423 | POS MERCHANDISE;MCDONALD'S #112 424 | 425 | ''' 426 | txn = soup_maker(input) 427 | transaction = OfxParser.parseTransaction(txn.find('stmttrn')) 428 | self.assertEqual('pos', transaction.type) 429 | self.assertEqual(datetime( 430 | 2009, 4, 1, 12, 20, 17) - timedelta(hours=-5), transaction.date) 431 | self.assertEqual(datetime(2009, 1, 31, 0, 0), transaction.user_date) 432 | self.assertEqual(Decimal('-6.60'), transaction.amount) 433 | self.assertEqual('0000123456782009040100001', transaction.id) 434 | self.assertEqual("MCDONALD'S #112", transaction.payee) 435 | self.assertEqual("POS MERCHANDISE;MCDONALD'S #112", transaction.memo) 436 | 437 | def testThatParseTransactionWithFieldCheckNum(self): 438 | input = ''' 439 | 440 | DEP 441 | 20130306 442 | 1000.00 443 | 2013030601009100 444 | 700 445 | DEPOSITO ONLINE 446 | 447 | ''' 448 | txn = soup_maker(input) 449 | transaction = OfxParser.parseTransaction(txn.find('stmttrn')) 450 | self.assertEqual('700', transaction.checknum) 451 | 452 | def testThatParseTransactionWithCommaAsDecimalPoint(self): 453 | input = ''' 454 | 455 | POS 456 | 20090401122017.000[-5:EST] 457 | -1006,60 458 | 0000123456782009040100001 459 | MCDONALD'S #112 460 | POS MERCHANDISE;MCDONALD'S #112 461 | 462 | ''' 463 | txn = soup_maker(input) 464 | transaction = OfxParser.parseTransaction(txn.find('stmttrn')) 465 | self.assertEqual(Decimal('-1006.60'), transaction.amount) 466 | 467 | def testThatParseTransactionWithCommaAsDecimalPointAndDotAsSeparator(self): 468 | input = ''' 469 | 470 | POS 471 | 20090401122017.000[-5:EST] 472 | -1.006,60 473 | 0000123456782009040100001 474 | MCDONALD'S #112 475 | POS MERCHANDISE;MCDONALD'S #112 476 | 477 | ''' 478 | txn = soup_maker(input) 479 | transaction = OfxParser.parseTransaction(txn.find('stmttrn')) 480 | self.assertEqual(Decimal('-1006.60'), transaction.amount) 481 | 482 | def testThatParseTransactionWithDotAsDecimalPointAndCommaAsSeparator(self): 483 | " The exact opposite of the previous test. Why are numbers so hard?" 484 | input = ''' 485 | 486 | POS 487 | 20090401122017.000[-5:EST] 488 | -1,006.60 489 | 0000123456782009040100001 490 | MCDONALD'S #112 491 | POS MERCHANDISE;MCDONALD'S #112 492 | 493 | ''' 494 | txn = soup_maker(input) 495 | transaction = OfxParser.parseTransaction(txn.find('stmttrn')) 496 | self.assertEqual(Decimal('-1006.60'), transaction.amount) 497 | 498 | def testThatParseTransactionWithLeadingPlusSign(self): 499 | " Parse numbers with a leading '+' sign. " 500 | input = ''' 501 | 502 | POS 503 | 20090401122017.000[-5:EST] 504 | +1,006.60 505 | 0000123456782009040100001 506 | MCDONALD'S #112 507 | POS MERCHANDISE;MCDONALD'S #112 508 | 509 | ''' 510 | txn = soup_maker(input) 511 | transaction = OfxParser.parseTransaction(txn.find('stmttrn')) 512 | self.assertEqual(Decimal('1006.60'), transaction.amount) 513 | 514 | def testThatParseTransactionWithSpaces(self): 515 | " Parse numbers with a space separating the thousands. " 516 | input = ''' 517 | 518 | POS 519 | 20090401122017.000[-5:EST] 520 | +1 006,60 521 | 0000123456782009040100001 522 | MCDONALD'S #112 523 | POS MERCHANDISE;MCDONALD'S #112 524 | 525 | ''' 526 | txn = soup_maker(input) 527 | transaction = OfxParser.parseTransaction(txn.find('stmttrn')) 528 | self.assertEqual(Decimal('1006.60'), transaction.amount) 529 | 530 | def testThatParseTransactionWithNullAmountIgnored(self): 531 | """A null transaction value is converted to 0. 532 | 533 | Some banks use a null transaction to include interest 534 | rate changes on statements. 535 | """ 536 | input_template = ''' 537 | 538 | DEP 539 | 20130306 540 | {amount} 541 | 2013030601009100 542 | 700 543 | DEPOSITO ONLINE 544 | 545 | ''' 546 | for amount in ("null", "-null"): 547 | input = input_template.format(amount=amount) 548 | txn = soup_maker(input) 549 | 550 | transaction = OfxParser.parseTransaction(txn.find('stmttrn')) 551 | 552 | self.assertEqual(0, transaction.amount) 553 | 554 | 555 | class TestTransaction(TestCase): 556 | def testThatAnEmptyTransactionIsValid(self): 557 | t = Transaction() 558 | self.assertEqual('', t.payee) 559 | self.assertEqual('', t.type) 560 | self.assertEqual(None, t.date) 561 | self.assertEqual(None, t.amount) 562 | self.assertEqual('', t.id) 563 | self.assertEqual('', t.memo) 564 | self.assertEqual('', t.checknum) 565 | 566 | 567 | class TestInvestmentAccount(TestCase): 568 | sample = ''' 569 | 570 | 572 | 573 | 574 | 575 | 38737714201101012011062420110624 576 | 577 | 0 578 | INFO 579 | 580 | 581 | 582 | 583 | 584 | 585 | ''' 586 | 587 | def testThatParseCanCreateAnInvestmentAccount(self): 588 | OfxParser.parse(six.BytesIO(six.b(self.sample))) 589 | # Success! 590 | 591 | 592 | class TestVanguardInvestmentStatement(TestCase): 593 | def testForUnclosedTags(self): 594 | with open_file('vanguard.ofx') as f: 595 | ofx = OfxParser.parse(f) 596 | self.assertTrue(hasattr(ofx, 'account')) 597 | self.assertTrue(hasattr(ofx.account, 'statement')) 598 | self.assertTrue(hasattr(ofx.account.statement, 'transactions')) 599 | self.assertEqual(len(ofx.account.statement.transactions), 1) 600 | self.assertEqual(ofx.account.statement.transactions[0].id, 601 | '01234567890.0123.07152011.0') 602 | self.assertEqual(ofx.account.statement.transactions[0] 603 | .tradeDate, datetime(2011, 7, 15, 21)) 604 | self.assertEqual(ofx.account.statement.transactions[0] 605 | .settleDate, datetime(2011, 7, 15, 21)) 606 | self.assertTrue(hasattr(ofx.account.statement, 'positions')) 607 | self.assertEqual(len(ofx.account.statement.positions), 2) 608 | self.assertEqual( 609 | ofx.account.statement.positions[0].units, Decimal('102.0')) 610 | 611 | def testSecurityListSuccess(self): 612 | with open_file('vanguard.ofx') as f: 613 | ofx = OfxParser.parse(f) 614 | self.assertEqual(len(ofx.security_list), 2) 615 | 616 | 617 | class TestVanguard401kStatement(TestCase): 618 | def testReadTransfer(self): 619 | with open_file('vanguard401k.ofx') as f: 620 | ofx = OfxParser.parse(f) 621 | self.assertTrue(hasattr(ofx, 'account')) 622 | self.assertTrue(hasattr(ofx.account, 'statement')) 623 | self.assertTrue(hasattr(ofx.account.statement, 'transactions')) 624 | self.assertEqual(len(ofx.account.statement.transactions), 5) 625 | self.assertEqual(ofx.account.statement.transactions[-1].id, 626 | '1234567890123456795AAA') 627 | self.assertEqual('transfer', ofx.account.statement.transactions[-1].type) 628 | self.assertEqual(ofx.account.statement.transactions[-1].inv401ksource, 629 | 'MATCH') 630 | 631 | 632 | class TestTiaaCrefStatement(TestCase): 633 | def testReadAccount(self): 634 | with open_file('tiaacref.ofx') as f: 635 | ofx = OfxParser.parse(f) 636 | self.assertTrue(hasattr(ofx, 'account')) 637 | self.assertTrue(hasattr(ofx.account, 'account_id')) 638 | self.assertEqual(ofx.account.account_id, '111A1111 22B222 33C333') 639 | self.assertTrue(hasattr(ofx.account, 'type')) 640 | self.assertEqual(ofx.account.type, AccountType.Investment) 641 | 642 | def testReadTransfer(self): 643 | with open_file('tiaacref.ofx') as f: 644 | ofx = OfxParser.parse(f) 645 | self.assertTrue(hasattr(ofx, 'account')) 646 | self.assertTrue(hasattr(ofx.account, 'statement')) 647 | self.assertTrue(hasattr(ofx.account.statement, 'transactions')) 648 | self.assertEqual(len(ofx.account.statement.transactions), 1) 649 | self.assertEqual( 650 | ofx.account.statement.transactions[-1].id, 651 | 'TIAA#20170307160000.000[-4:EDT]160000.000[-4:EDT]' 652 | ) 653 | self.assertEqual( 654 | 'transfer', 655 | ofx.account.statement.transactions[-1].type 656 | ) 657 | 658 | def testReadPositions(self): 659 | with open_file('tiaacref.ofx') as f: 660 | ofx = OfxParser.parse(f) 661 | self.assertTrue(hasattr(ofx, 'account')) 662 | self.assertTrue(hasattr(ofx.account, 'statement')) 663 | self.assertTrue(hasattr(ofx.account.statement, 'positions')) 664 | expected_positions = [ 665 | { 666 | 'security': '222222126', 667 | 'units': Decimal('13.0763'), 668 | 'unit_price': Decimal('1.0000'), 669 | 'market_value': Decimal('13.0763') 670 | }, 671 | { 672 | 'security': '222222217', 673 | 'units': Decimal('1.0000'), 674 | 'unit_price': Decimal('25.5785'), 675 | 'market_value': Decimal('25.5785') 676 | }, 677 | { 678 | 'security': '222222233', 679 | 'units': Decimal('8.7605'), 680 | 'unit_price': Decimal('12.4823'), 681 | 'market_value': Decimal('109.3512') 682 | }, 683 | { 684 | 'security': '222222258', 685 | 'units': Decimal('339.2012'), 686 | 'unit_price': Decimal('12.3456'), 687 | 'market_value': Decimal('4187.6423') 688 | }, 689 | { 690 | 'security': '111111111', 691 | 'units': Decimal('543.71'), 692 | 'unit_price': Decimal('1'), 693 | 'market_value': Decimal('543.71') 694 | }, 695 | { 696 | 'security': '333333200', 697 | 'units': Decimal('2.00'), 698 | 'unit_price': Decimal('10.00'), 699 | 'market_value': Decimal('20.00') 700 | } 701 | ] 702 | self.assertEqual( 703 | len(ofx.account.statement.positions), 704 | len(expected_positions) 705 | ) 706 | for pos, expected_pos in zip( 707 | ofx.account.statement.positions, expected_positions 708 | ): 709 | self.assertEqual(pos.security, expected_pos['security']) 710 | self.assertEqual(pos.units, expected_pos['units']) 711 | self.assertEqual(pos.unit_price, expected_pos['unit_price']) 712 | self.assertEqual(pos.market_value, expected_pos['market_value']) 713 | 714 | 715 | class TestFidelityInvestmentStatement(TestCase): 716 | def testForUnclosedTags(self): 717 | with open_file('fidelity.ofx') as f: 718 | ofx = OfxParser.parse(f) 719 | self.assertTrue(hasattr(ofx.account.statement, 'positions')) 720 | self.assertEqual(len(ofx.account.statement.positions), 6) 721 | self.assertEqual( 722 | ofx.account.statement.positions[0].units, Decimal('128.0')) 723 | self.assertEqual( 724 | ofx.account.statement.positions[0].market_value, Decimal('5231.36') 725 | ) 726 | 727 | def testSecurityListSuccess(self): 728 | with open_file('fidelity.ofx') as f: 729 | ofx = OfxParser.parse(f) 730 | self.assertEqual(len(ofx.security_list), 7) 731 | 732 | def testBalanceList(self): 733 | with open_file('fidelity.ofx') as f: 734 | ofx = OfxParser.parse(f) 735 | self.assertEqual(len(ofx.account.statement.balance_list), 18) 736 | self.assertEqual(ofx.account.statement.balance_list[0].name, 'Networth') 737 | self.assertEqual(ofx.account.statement.balance_list[0].description, 'The net market value of all long and short positions in the account') 738 | self.assertEqual(ofx.account.statement.balance_list[0].value, Decimal('32993.79')) 739 | self.assertEqual(ofx.account.statement.available_cash, Decimal('18073.98')) 740 | self.assertEqual(ofx.account.statement.margin_balance, Decimal('0')) 741 | self.assertEqual(ofx.account.statement.short_balance, Decimal('0')) 742 | self.assertEqual(ofx.account.statement.buy_power, Decimal('0')) 743 | 744 | class TestFidelitySavingsStatement(TestCase): 745 | def testSTMTTRNInInvestmentBank(self): 746 | with open_file('fidelity-savings.ofx') as f: 747 | ofx = OfxParser.parse(f) 748 | 749 | self.assertTrue(hasattr(ofx.account.statement, 'transactions')) 750 | self.assertEqual(len(ofx.account.statement.transactions), 4) 751 | 752 | tx = ofx.account.statement.transactions[0] 753 | self.assertEqual('check', tx.type) 754 | self.assertEqual(datetime( 755 | 2012, 7, 20, 0, 0, 0) - timedelta(hours=-4), tx.date) 756 | self.assertEqual(Decimal('-1500.00'), tx.amount) 757 | self.assertEqual('X0000000000000000000001', tx.id) 758 | self.assertEqual('Check Paid #0000001001', tx.payee) 759 | self.assertEqual('Check Paid #0000001001', tx.memo) 760 | 761 | tx = ofx.account.statement.transactions[1] 762 | self.assertEqual('dep', tx.type) 763 | self.assertEqual(datetime( 764 | 2012, 7, 27, 0, 0, 0) - timedelta(hours=-4), tx.date) 765 | self.assertEqual(Decimal('115.8331'), tx.amount) 766 | self.assertEqual('X0000000000000000000002', tx.id) 767 | self.assertEqual('TRANSFERRED FROM VS X10-08144', tx.payee) 768 | self.assertEqual('TRANSFERRED FROM VS X10-08144-1', tx.memo) 769 | 770 | class Test401InvestmentStatement(TestCase): 771 | def testTransferAggregate(self): 772 | with open_file('investment_401k.ofx') as f: 773 | ofx = OfxParser.parse(f) 774 | expected_txns = [{'id': '1', 775 | 'type': 'buymf', 776 | 'units': Decimal('8.846699'), 777 | 'unit_price': Decimal('22.2908'), 778 | 'total': Decimal('-197.2'), 779 | 'security': 'FOO', 780 | 'tferaction': None}, 781 | {'id': '2', 782 | 'type': 'transfer', 783 | 'units': Decimal('6.800992'), 784 | 'unit_price': Decimal('29.214856'), 785 | 'total': Decimal('0.0'), 786 | 'security': 'BAR', 787 | 'tferaction': 'IN'}, 788 | {'id': '3', 789 | 'type': 'transfer', 790 | 'units': Decimal('-9.060702'), 791 | 'unit_price': Decimal('21.928764'), 792 | 'total': Decimal('0.0'), 793 | 'security': 'BAZ', 794 | 'tferaction': 'OUT'}] 795 | for txn, expected_txn in zip(ofx.account.statement.transactions, expected_txns): 796 | self.assertEqual(txn.id, expected_txn['id']) 797 | self.assertEqual(txn.type, expected_txn['type']) 798 | self.assertEqual(txn.units, expected_txn['units']) 799 | self.assertEqual(txn.unit_price, expected_txn['unit_price']) 800 | self.assertEqual(txn.total, expected_txn['total']) 801 | self.assertEqual(txn.security, expected_txn['security']) 802 | self.assertEqual(txn.tferaction, expected_txn['tferaction']) 803 | 804 | expected_positions = [ 805 | { 806 | 'security': 'FOO', 807 | 'units': Decimal('17.604312'), 808 | 'unit_price': Decimal('22.517211'), 809 | 'market_value': Decimal('396.4') 810 | }, 811 | { 812 | 'security': 'BAR', 813 | 'units': Decimal('13.550983'), 814 | 'unit_price': Decimal('29.214855'), 815 | 'market_value': Decimal('395.89') 816 | }, 817 | { 818 | 'security': 'BAZ', 819 | 'units': Decimal('0.0'), 820 | 'unit_price': Decimal('0.0'), 821 | 'market_value': Decimal('0.0') 822 | } 823 | ] 824 | for pos, expected_pos in zip(ofx.account.statement.positions, expected_positions): 825 | self.assertEqual(pos.security, expected_pos['security']) 826 | self.assertEqual(pos.units, expected_pos['units']) 827 | self.assertEqual(pos.unit_price, expected_pos['unit_price']) 828 | self.assertEqual(pos.market_value, expected_pos['market_value']) 829 | 830 | 831 | class TestEmptyTagsOFXv102(TestCase): 832 | """ Test an OFX v1.02 file with the following empty fields: 833 | * STMTRS.BANKACCTFROM.BRANCHID (optional according to spec) 834 | * STMTRS.BANKACCTFROM.ACCTTYPE (mandatory) 835 | * STMTRS.CURDEF (mandatory) 836 | * STMTRS.BANKTRANLIST.STMTTRN.FITID 837 | * STMTRS.BANKTRANLIST.STMTTRN.NAME 838 | * STMTRS.BANKTRANLIST.STMTTRN.REFNUM (optional) 839 | * STMTRS.BANKTRANLIST.STMTTRN.CHECKNUM (optional) 840 | This file is from Newcastle Permanent in Australia 841 | """ 842 | def testEmptyAccountTags(self): 843 | with open_file('ofx-v102-empty-tags.ofx') as f: 844 | ofx = OfxParser.parse(f, fail_fast=False) 845 | 846 | account = ofx.accounts[0] 847 | 848 | # Verify empty tags have empty values 849 | self.assertEqual(account.branch_id, '') 850 | self.assertEqual(account.account_type, '') 851 | self.assertEqual(account.curdef, None) 852 | 853 | # Verify non-empty tags are processed 854 | self.assertEqual(account.account_id, "12345678") 855 | # Bank-generated OFX uses org name in bankid field 856 | self.assertEqual(account.routing_number, "NPBS") 857 | 858 | def testMissingTransactionHeader(self): 859 | with open_file('ofx-v102-empty-tags.ofx') as f: 860 | ofx = OfxParser.parse(f, fail_fast=False) 861 | 862 | # Empty currency definition 863 | self.assertTrue(ofx.accounts[0].statement.warnings[0].startswith("Currency definition was empty for ")) 864 | 865 | 866 | class TestSuncorpBankStatement(TestCase): 867 | def testCDATATransactions(self): 868 | with open_file('suncorp.ofx') as f: 869 | ofx = OfxParser.parse(f) 870 | accounts = ofx.accounts 871 | self.assertEqual(len(accounts), 1) 872 | account = accounts[0] 873 | transactions = account.statement.transactions 874 | self.assertEqual(len(transactions), 1) 875 | transaction = transactions[0] 876 | self.assertEqual(transaction.payee, "EFTPOS WDL HANDYWAY ALDI STORE") 877 | self.assertEqual( 878 | transaction.memo, 879 | "EFTPOS WDL HANDYWAY ALDI STORE GEELONG WEST VICAU") 880 | self.assertEqual(transaction.amount, Decimal("-16.85")) 881 | 882 | class TestTDAmeritrade(TestCase): 883 | def testPositions(self): 884 | with open_file('td_ameritrade.ofx') as f: 885 | ofx = OfxParser.parse(f) 886 | account = ofx.accounts[0] 887 | statement = account.statement 888 | positions = statement.positions 889 | self.assertEqual(len(positions), 2) 890 | 891 | expected_positions = [ 892 | { 893 | 'security': '023135106', 894 | 'units': Decimal('1'), 895 | 'unit_price': Decimal('1000'), 896 | 'market_value': Decimal('1000') 897 | }, 898 | { 899 | 'security': '912810RW0', 900 | 'units': Decimal('1000'), 901 | 'unit_price': Decimal('100'), 902 | 'market_value': Decimal('1000') 903 | } 904 | ] 905 | for pos, expected_pos in zip(positions, expected_positions): 906 | self.assertEqual(pos.security, expected_pos['security']) 907 | self.assertEqual(pos.units, expected_pos['units']) 908 | self.assertEqual(pos.unit_price, expected_pos['unit_price']) 909 | self.assertEqual(pos.market_value, expected_pos['market_value']) 910 | 911 | expected_securities = [ 912 | { 913 | 'uniqueid': '023135106', 914 | 'ticker': 'AMZN', 915 | 'name': 'Amazon.com, Inc. - Common Stock' 916 | }, 917 | { 918 | 'uniqueid': '912810RW0', 919 | 'ticker': '912810RW0', 920 | 'name': 'US Treasury 2047' 921 | } 922 | ] 923 | for sec, expected_sec in zip(ofx.security_list, expected_securities): 924 | self.assertEqual(sec.uniqueid, expected_sec['uniqueid']) 925 | self.assertEqual(sec.ticker, expected_sec['ticker']) 926 | self.assertEqual(sec.name, expected_sec['name']) 927 | 928 | class TestAccountInfoAggregation(TestCase): 929 | def testForFourAccounts(self): 930 | with open_file('account_listing_aggregation.ofx') as f: 931 | ofx = OfxParser.parse(f) 932 | self.assertTrue(hasattr(ofx, 'accounts')) 933 | self.assertEqual(len(ofx.accounts), 4) 934 | 935 | # first account 936 | account = ofx.accounts[0] 937 | self.assertEqual(account.account_type, 'SAVINGS') 938 | self.assertEqual(account.desc, 'USAA SAVINGS') 939 | self.assertEqual(account.institution.organization, 'USAA') 940 | self.assertEqual(account.number, '0000000001') 941 | self.assertEqual(account.routing_number, '314074269') 942 | 943 | # second 944 | account = ofx.accounts[1] 945 | self.assertEqual(account.account_type, 'CHECKING') 946 | self.assertEqual(account.desc, 'FOUR STAR CHECKING') 947 | self.assertEqual(account.institution.organization, 'USAA') 948 | self.assertEqual(account.number, '0000000002') 949 | self.assertEqual(account.routing_number, '314074269') 950 | 951 | # third 952 | account = ofx.accounts[2] 953 | self.assertEqual(account.account_type, 'CREDITLINE') 954 | self.assertEqual(account.desc, 'LINE OF CREDIT') 955 | self.assertEqual(account.institution.organization, 'USAA') 956 | self.assertEqual(account.number, '00000000000003') 957 | self.assertEqual(account.routing_number, '314074269') 958 | 959 | # fourth 960 | account = ofx.accounts[3] 961 | self.assertEqual(account.account_type, '') 962 | self.assertEqual(account.desc, 'MY CREDIT CARD') 963 | self.assertEqual(account.institution.organization, 'USAA') 964 | self.assertEqual(account.number, '4111111111111111') 965 | 966 | 967 | class TestGracefulFailures(TestCase): 968 | ''' Test that when fail_fast is False, failures are returned to the 969 | caller as warnings and discarded entries in the Statement class. 970 | ''' 971 | def testDateFieldMissing(self): 972 | ''' The test file contains three transactions in a single 973 | statement. 974 | 975 | They fail due to: 976 | 1) No date 977 | 2) Empty date 978 | 3) Invalid date 979 | ''' 980 | with open_file('fail_nice/date_missing.ofx') as f: 981 | ofx = OfxParser.parse(f, False) 982 | self.assertEqual(len(ofx.account.statement.transactions), 0) 983 | self.assertEqual(len(ofx.account.statement.discarded_entries), 3) 984 | self.assertEqual(len(ofx.account.statement.warnings), 0) 985 | 986 | # Test that it raises an error otherwise. 987 | with open_file('fail_nice/date_missing.ofx') as f: 988 | self.assertRaises(OfxParserException, OfxParser.parse, f) 989 | 990 | def testDecimalConversionError(self): 991 | ''' The test file contains a transaction that has a poorly formatted 992 | decimal number ($20). Test that we catch this. 993 | ''' 994 | with open_file('fail_nice/decimal_error.ofx') as f: 995 | ofx = OfxParser.parse(f, False) 996 | self.assertEqual(len(ofx.account.statement.transactions), 0) 997 | self.assertEqual(len(ofx.account.statement.discarded_entries), 1) 998 | 999 | # Test that it raises an error otherwise. 1000 | with open_file('fail_nice/decimal_error.ofx') as f: 1001 | self.assertRaises(OfxParserException, OfxParser.parse, f) 1002 | 1003 | def testEmptyBalance(self): 1004 | ''' The test file contains empty or blank strings in the balance 1005 | fields. Fail nicely on those. 1006 | ''' 1007 | with open_file('fail_nice/empty_balance.ofx') as f: 1008 | ofx = OfxParser.parse(f, False) 1009 | self.assertEqual(len(ofx.account.statement.transactions), 1) 1010 | self.assertEqual(len(ofx.account.statement.discarded_entries), 0) 1011 | self.assertFalse(hasattr(ofx.account.statement, 'balance')) 1012 | self.assertFalse(hasattr(ofx.account.statement, 'available_balance')) 1013 | 1014 | # Test that it raises an error otherwise. 1015 | with open_file('fail_nice/empty_balance.ofx') as f: 1016 | self.assertRaises(OfxParserException, OfxParser.parse, f) 1017 | 1018 | def testErrorInTransactionList(self): 1019 | """There is an error in the transaction list.""" 1020 | with open_file('error_message.ofx') as f: 1021 | ofx = OfxParser.parse(f, False) 1022 | self.assertEqual(ofx.status['code'], 2000) 1023 | self.assertEqual(ofx.status['severity'], 'ERROR') 1024 | self.assertEqual(ofx.status['message'], 'General Server Error') 1025 | 1026 | 1027 | class TestParseSonrs(TestCase): 1028 | 1029 | def testSuccess(self): 1030 | with open_file('signon_success.ofx') as f: 1031 | ofx = OfxParser.parse(f, True) 1032 | self.assertTrue(ofx.signon.success) 1033 | self.assertEqual(ofx.signon.code, 0) 1034 | self.assertEqual(ofx.signon.severity, 'INFO') 1035 | self.assertEqual(ofx.signon.message, 'Login successful') 1036 | 1037 | with open_file('signon_success_no_message.ofx') as f: 1038 | ofx = OfxParser.parse(f, True) 1039 | self.assertTrue(ofx.signon.success) 1040 | self.assertEqual(ofx.signon.code, 0) 1041 | self.assertEqual(ofx.signon.severity, 'INFO') 1042 | self.assertEqual(ofx.signon.message, '') 1043 | 1044 | def testFailure(self): 1045 | with open_file('signon_fail.ofx') as f: 1046 | ofx = OfxParser.parse(f, True) 1047 | self.assertFalse(ofx.signon.success) 1048 | self.assertEqual(ofx.signon.code, 15500) 1049 | self.assertEqual(ofx.signon.severity, 'ERROR') 1050 | self.assertEqual(ofx.signon.message, 'Your request could not be processed because you supplied an invalid identification code or your password was incorrect') 1051 | 1052 | if __name__ == "__main__": 1053 | import unittest 1054 | unittest.main() 1055 | -------------------------------------------------------------------------------- /ofxparse/ofxparse.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import sys 4 | import decimal 5 | import datetime 6 | import codecs 7 | import re 8 | import collections 9 | import contextlib 10 | 11 | try: 12 | from StringIO import StringIO 13 | except ImportError: 14 | from io import StringIO 15 | 16 | try: 17 | from collections.abc import Iterable 18 | except ImportError: 19 | from collections import Iterable 20 | 21 | import six 22 | from . import mcc 23 | 24 | odict = collections 25 | 26 | try: 27 | from bs4 import BeautifulSoup 28 | 29 | def soup_maker(fh): 30 | return BeautifulSoup(fh, 'html.parser') 31 | except ImportError: 32 | from BeautifulSoup import BeautifulStoneSoup 33 | soup_maker = BeautifulStoneSoup 34 | 35 | 36 | def try_decode(string, encoding): 37 | if hasattr(string, 'decode'): 38 | string = string.decode(encoding) 39 | return string 40 | 41 | 42 | def is_iterable(candidate): 43 | if sys.version_info < (2, 6): 44 | return hasattr(candidate, 'next') 45 | return isinstance(candidate, Iterable) 46 | 47 | 48 | @contextlib.contextmanager 49 | def save_pos(fh): 50 | """ 51 | Save the position of the file handle, seek to the beginning, and 52 | then restore the position. 53 | """ 54 | orig_pos = fh.tell() 55 | fh.seek(0) 56 | try: 57 | yield fh 58 | finally: 59 | fh.seek(orig_pos) 60 | 61 | 62 | class OfxFile(object): 63 | def __init__(self, fh): 64 | """ 65 | fh should be a seekable file-like byte stream object 66 | """ 67 | self.headers = odict.OrderedDict() 68 | self.fh = fh 69 | 70 | if not is_iterable(self.fh): 71 | return 72 | if not hasattr(self.fh, "seek"): 73 | return # fh is not a file object, we're doomed. 74 | 75 | # If the file handler is text stream, convert to bytes one: 76 | first = self.fh.read(1) 77 | self.fh.seek(0) 78 | if not isinstance(first, bytes): 79 | self.fh = six.BytesIO(six.b(self.fh.read())) 80 | 81 | with save_pos(self.fh): 82 | self.read_headers() 83 | self.handle_encoding() 84 | self.replace_NONE_headers() 85 | 86 | def read_headers(self): 87 | head_data = self.fh.read(1024 * 10) 88 | head_data = head_data[:head_data.find(six.b('<'))] 89 | 90 | for line in head_data.splitlines(): 91 | # Newline? 92 | if line.strip() == six.b(""): 93 | break 94 | 95 | header, value = line.split(six.b(":")) 96 | header, value = header.strip().upper(), value.strip() 97 | 98 | self.headers[header] = value 99 | 100 | def handle_encoding(self): 101 | """ 102 | Decode the headers and wrap self.fh in a decoder such that it 103 | subsequently returns only text. 104 | """ 105 | # decode the headers using ascii 106 | ascii_headers = odict.OrderedDict( 107 | ( 108 | key.decode('ascii', 'replace'), 109 | value.decode('ascii', 'replace'), 110 | ) 111 | for key, value in six.iteritems(self.headers) 112 | ) 113 | 114 | enc_type = ascii_headers.get('ENCODING') 115 | 116 | if not enc_type: 117 | # no encoding specified, use the ascii-decoded headers 118 | self.headers = ascii_headers 119 | # decode the body as ascii as well 120 | self.fh = codecs.lookup('ascii').streamreader(self.fh) 121 | return 122 | 123 | if enc_type == "USASCII": 124 | cp = ascii_headers.get("CHARSET", "1252") 125 | if cp == "8859-1": 126 | encoding = "iso-8859-1" 127 | else: 128 | encoding = "cp%s" % (cp, ) 129 | 130 | elif enc_type in ("UNICODE", "UTF-8"): 131 | encoding = "utf-8" 132 | 133 | codec = codecs.lookup(encoding) 134 | 135 | self.fh = codec.streamreader(self.fh) 136 | 137 | # Decode the headers using the encoding 138 | self.headers = odict.OrderedDict( 139 | (key.decode(encoding), value.decode(encoding)) 140 | for key, value in six.iteritems(self.headers) 141 | ) 142 | 143 | def replace_NONE_headers(self): 144 | """ 145 | Any headers that indicate 'none' should be replaced with Python 146 | None values 147 | """ 148 | for header in self.headers: 149 | if self.headers[header].upper() == 'NONE': 150 | self.headers[header] = None 151 | 152 | 153 | class OfxPreprocessedFile(OfxFile): 154 | def __init__(self, fh): 155 | super(OfxPreprocessedFile, self).__init__(fh) 156 | 157 | if self.fh is None: 158 | return 159 | 160 | ofx_string = self.fh.read() 161 | 162 | # find all closing tags as hints 163 | closing_tags = [t.upper() for t in re.findall(r'(?i)', 164 | ofx_string)] 165 | 166 | # close all tags that don't have closing tags and 167 | # leave all other data intact 168 | last_open_tag = None 169 | tokens = re.split(r'(?i)()', ofx_string) 170 | new_fh = StringIO() 171 | for token in tokens: 172 | is_closing_tag = token.startswith('" % last_open_tag) 181 | last_open_tag = None 182 | if is_open_tag: 183 | tag_name = re.findall(r'(?i)<([a-z0-9_\.]+)>', token)[0] 184 | if tag_name.upper() not in closing_tags: 185 | last_open_tag = tag_name 186 | new_fh.write(token) 187 | new_fh.seek(0) 188 | self.fh = new_fh 189 | 190 | 191 | class Ofx(object): 192 | def __str__(self): 193 | return "" 194 | # headers = "\r\n".join(":".join(el if el else "NONE" for el in item) 195 | # for item in six.iteritems(self.headers)) 196 | # headers += "\r\n\r\n" 197 | # 198 | # return headers + str(self.signon) 199 | 200 | 201 | class AccountType(object): 202 | (Unknown, Bank, CreditCard, Investment) = range(0, 4) 203 | 204 | 205 | class Account(object): 206 | def __init__(self): 207 | self.curdef = None 208 | self.statement = None 209 | self.account_id = '' 210 | self.routing_number = '' 211 | self.branch_id = '' 212 | self.account_type = '' 213 | self.institution = None 214 | self.type = AccountType.Unknown 215 | # Used for error tracking 216 | self.warnings = [] 217 | 218 | @property 219 | def number(self): 220 | # For backwards compatibility. Remove in version 1.0. 221 | return self.account_id 222 | 223 | 224 | class InvestmentAccount(Account): 225 | def __init__(self): 226 | super(InvestmentAccount, self).__init__() 227 | self.brokerid = '' 228 | 229 | 230 | class BrokerageBalance: 231 | def __init__(self): 232 | self.name = None 233 | self.description = None 234 | self.value = None # decimal 235 | 236 | 237 | class Security: 238 | def __init__(self, uniqueid, name, ticker, memo): 239 | self.uniqueid = uniqueid 240 | self.name = name 241 | self.ticker = ticker 242 | self.memo = memo 243 | 244 | 245 | class Signon: 246 | def __init__(self, keys): 247 | self.code = keys['code'] 248 | self.severity = keys['severity'] 249 | self.message = keys['message'] 250 | self.dtserver = keys['dtserver'] 251 | self.language = keys['language'] 252 | self.dtprofup = keys['dtprofup'] 253 | self.fi_org = keys['org'] 254 | self.fi_fid = keys['fid'] 255 | self.intu_bid = keys['intu.bid'] 256 | 257 | if int(self.code) == 0: 258 | self.success = True 259 | else: 260 | self.success = False 261 | 262 | def __str__(self): 263 | ret = "\t\r\n" + "\t\t\r\n" + \ 264 | "\t\t\t\r\n" 265 | ret += "\t\t\t\t%s\r\n" % self.code 266 | ret += "\t\t\t\t%s\r\n" % self.severity 267 | if self.message: 268 | ret += "\t\t\t\t%s\r\n" % self.message 269 | ret += "\t\t\t\r\n" 270 | if self.dtserver is not None: 271 | ret += "\t\t\t" + self.dtserver + "\r\n" 272 | if self.language is not None: 273 | ret += "\t\t\t" + self.language + "\r\n" 274 | if self.dtprofup is not None: 275 | ret += "\t\t\t" + self.dtprofup + "\r\n" 276 | if (self.fi_org is not None) or (self.fi_fid is not None): 277 | ret += "\t\t\t\r\n" 278 | if self.fi_org is not None: 279 | ret += "\t\t\t\t" + self.fi_org + "\r\n" 280 | if self.fi_fid is not None: 281 | ret += "\t\t\t\t" + self.fi_fid + "\r\n" 282 | ret += "\t\t\t\r\n" 283 | if self.intu_bid is not None: 284 | ret += "\t\t\t" + self.intu_bid + "\r\n" 285 | ret += "\t\t\r\n" 286 | ret += "\t\r\n" 287 | return ret 288 | 289 | 290 | class Statement(object): 291 | def __init__(self): 292 | self.start_date = '' 293 | self.end_date = '' 294 | self.currency = '' 295 | self.transactions = [] 296 | # Error tracking: 297 | self.discarded_entries = [] 298 | self.warnings = [] 299 | 300 | 301 | class InvestmentStatement(object): 302 | def __init__(self): 303 | self.positions = [] 304 | self.transactions = [] 305 | # Error tracking: 306 | self.discarded_entries = [] 307 | self.warnings = [] 308 | 309 | 310 | class Transaction(object): 311 | def __init__(self): 312 | self.payee = '' 313 | self.type = '' 314 | self.date = None 315 | self.user_date = None 316 | self.amount = None 317 | self.id = '' 318 | self.memo = '' 319 | self.sic = None 320 | self.mcc = '' 321 | self.checknum = '' 322 | 323 | def __repr__(self): 324 | return "" 325 | 326 | 327 | class InvestmentTransaction(object): 328 | AGGREGATE_TYPES = ['buydebt', 'buymf', 'buyopt', 'buyother', 329 | 'buystock', 'closureopt', 'income', 330 | 'invexpense', 'jrnlfund', 'jrnlsec', 331 | 'margininterest', 'reinvest', 'retofcap', 332 | 'selldebt', 'sellmf', 'sellopt', 'sellother', 333 | 'sellstock', 'split', 'transfer'] 334 | 335 | def __init__(self, type): 336 | self.type = type.lower() 337 | self.tradeDate = None 338 | self.settleDate = None 339 | self.memo = '' 340 | self.security = '' 341 | self.income_type = '' 342 | self.units = decimal.Decimal(0) 343 | self.unit_price = decimal.Decimal(0) 344 | self.commission = decimal.Decimal(0) 345 | self.fees = decimal.Decimal(0) 346 | self.total = decimal.Decimal(0) 347 | self.tferaction = None 348 | 349 | def __repr__(self): 350 | return "" 352 | 353 | 354 | class Position(object): 355 | def __init__(self): 356 | self.security = '' 357 | self.units = decimal.Decimal(0) 358 | self.unit_price = decimal.Decimal(0) 359 | self.market_value = decimal.Decimal(0) 360 | 361 | 362 | class Institution(object): 363 | def __init__(self): 364 | self.organization = '' 365 | self.fid = '' 366 | 367 | 368 | class OfxParserException(Exception): 369 | pass 370 | 371 | 372 | class OfxParser(object): 373 | @classmethod 374 | def parse(cls, file_handle, fail_fast=True, custom_date_format=None): 375 | ''' 376 | parse is the main entry point for an OfxParser. It takes a file 377 | handle and an optional log_errors flag. 378 | 379 | If fail_fast is True, the parser will fail on any errors. 380 | If fail_fast is False, the parser will log poor statements in the 381 | statement class and continue to run. Note: the library does not 382 | guarantee that no exceptions will be raised to the caller, only 383 | that statements will include bad transactions (which are marked). 384 | 385 | ''' 386 | cls.fail_fast = fail_fast 387 | cls.custom_date_format = custom_date_format 388 | 389 | if not hasattr(file_handle, 'seek'): 390 | raise TypeError(six.u('parse() accepts a seek-able file handle\ 391 | , not %s' % type(file_handle).__name__)) 392 | 393 | ofx_obj = Ofx() 394 | 395 | # Store the headers 396 | ofx_file = OfxPreprocessedFile(file_handle) 397 | ofx_obj.headers = ofx_file.headers 398 | ofx_obj.accounts = [] 399 | ofx_obj.signon = None 400 | 401 | ofx = soup_maker(ofx_file.fh) 402 | if ofx.find('ofx') is None: 403 | raise OfxParserException('The ofx file is empty!') 404 | 405 | sonrs_ofx = ofx.find('sonrs') 406 | if sonrs_ofx: 407 | ofx_obj.signon = cls.parseSonrs(sonrs_ofx) 408 | 409 | stmttrnrs = ofx.find('stmttrnrs') 410 | if stmttrnrs: 411 | stmttrnrs_trnuid = stmttrnrs.find('trnuid') 412 | if stmttrnrs_trnuid: 413 | ofx_obj.trnuid = stmttrnrs_trnuid.contents[0].strip() 414 | 415 | stmttrnrs_status = stmttrnrs.find('status') 416 | if stmttrnrs_status: 417 | ofx_obj.status = {} 418 | ofx_obj.status['code'] = int( 419 | stmttrnrs_status.find('code').contents[0].strip() 420 | ) 421 | ofx_obj.status['severity'] = \ 422 | stmttrnrs_status.find('severity').contents[0].strip() 423 | message = stmttrnrs_status.find('message') 424 | ofx_obj.status['message'] = \ 425 | message.contents[0].strip() if message else None 426 | 427 | ccstmttrnrs = ofx.find('ccstmttrnrs') 428 | if ccstmttrnrs: 429 | ccstmttrnrs_trnuid = ccstmttrnrs.find('trnuid') 430 | if ccstmttrnrs_trnuid: 431 | ofx_obj.trnuid = ccstmttrnrs_trnuid.contents[0].strip() 432 | 433 | ccstmttrnrs_status = ccstmttrnrs.find('status') 434 | if ccstmttrnrs_status: 435 | ofx_obj.status = {} 436 | ofx_obj.status['code'] = int( 437 | ccstmttrnrs_status.find('code').contents[0].strip() 438 | ) 439 | ofx_obj.status['severity'] = \ 440 | ccstmttrnrs_status.find('severity').contents[0].strip() 441 | message = ccstmttrnrs_status.find('message') 442 | ofx_obj.status['message'] = \ 443 | message.contents[0].strip() if message else None 444 | 445 | stmtrs_ofx = ofx.findAll('stmtrs') 446 | if stmtrs_ofx: 447 | ofx_obj.accounts += cls.parseStmtrs(stmtrs_ofx, AccountType.Bank) 448 | 449 | ccstmtrs_ofx = ofx.findAll('ccstmtrs') 450 | if ccstmtrs_ofx: 451 | ofx_obj.accounts += cls.parseStmtrs( 452 | ccstmtrs_ofx, AccountType.CreditCard) 453 | 454 | invstmtrs_ofx = ofx.findAll('invstmtrs') 455 | if invstmtrs_ofx: 456 | ofx_obj.accounts += cls.parseInvstmtrs(invstmtrs_ofx) 457 | seclist_ofx = ofx.find('seclist') 458 | if seclist_ofx: 459 | ofx_obj.security_list = cls.parseSeclist(seclist_ofx) 460 | else: 461 | ofx_obj.security_list = None 462 | 463 | acctinfors_ofx = ofx.find('acctinfors') 464 | if acctinfors_ofx: 465 | ofx_obj.accounts += cls.parseAcctinfors(acctinfors_ofx, ofx) 466 | 467 | fi_ofx = ofx.find('fi') 468 | if fi_ofx: 469 | for account in ofx_obj.accounts: 470 | account.institution = cls.parseOrg(fi_ofx) 471 | 472 | if ofx_obj.accounts: 473 | ofx_obj.account = ofx_obj.accounts[0] 474 | 475 | return ofx_obj 476 | 477 | @classmethod 478 | def parseOfxDateTime(cls, ofxDateTime): 479 | # dateAsString looks something like 20101106160000.00[-5:EST] 480 | # for 6 Nov 2010 4pm UTC-5 aka EST 481 | 482 | # Some places (e.g. Newfoundland) have non-integer offsets. 483 | res = re.search(r"\[(?P[-+]?\d+\.?\d*)\:\w*\]$", ofxDateTime) 484 | if res: 485 | tz = float(res.group('tz')) 486 | else: 487 | tz = 0 488 | 489 | timeZoneOffset = datetime.timedelta(hours=tz) 490 | 491 | res = re.search(r"^[0-9]*\.([0-9]{0,5})", ofxDateTime) 492 | if res: 493 | msec = datetime.timedelta(seconds=float("0." + res.group(1))) 494 | else: 495 | msec = datetime.timedelta(seconds=0) 496 | 497 | try: 498 | local_date = datetime.datetime.strptime(ofxDateTime[:14], '%Y%m%d%H%M%S') 499 | return local_date - timeZoneOffset + msec 500 | except ValueError: 501 | if ofxDateTime[:8] == "00000000": 502 | return None 503 | 504 | if not cls.custom_date_format: 505 | return datetime.datetime.strptime( 506 | ofxDateTime[:8], '%Y%m%d') - timeZoneOffset + msec 507 | else: 508 | return datetime.datetime.strptime( 509 | ofxDateTime[:8], cls.custom_date_format) - timeZoneOffset + msec 510 | 511 | @classmethod 512 | def parseAcctinfors(cls, acctinfors_ofx, ofx): 513 | all_accounts = [] 514 | for i in acctinfors_ofx.findAll('acctinfo'): 515 | accounts = [] 516 | if i.find('invacctinfo'): 517 | accounts += cls.parseInvstmtrs([i]) 518 | elif i.find('ccacctinfo'): 519 | accounts += cls.parseStmtrs([i], AccountType.CreditCard) 520 | elif i.find('bankacctinfo'): 521 | accounts += cls.parseStmtrs([i], AccountType.Bank) 522 | else: 523 | continue 524 | 525 | fi_ofx = ofx.find('fi') 526 | if fi_ofx: 527 | for account in all_accounts: 528 | account.institution = cls.parseOrg(fi_ofx) 529 | 530 | desc = i.find('desc') 531 | if hasattr(desc, 'contents'): 532 | for account in accounts: 533 | account.desc = desc.contents[0].strip() 534 | all_accounts += accounts 535 | return all_accounts 536 | 537 | @classmethod 538 | def parseInvstmtrs(cls, invstmtrs_list): 539 | ret = [] 540 | for invstmtrs_ofx in invstmtrs_list: 541 | account = InvestmentAccount() 542 | acctid_tag = invstmtrs_ofx.find('acctid') 543 | if hasattr(acctid_tag, 'contents'): 544 | try: 545 | account.account_id = acctid_tag.contents[0].strip() 546 | except IndexError: 547 | account.warnings.append( 548 | six.u("Empty acctid tag for %s") % invstmtrs_ofx) 549 | if cls.fail_fast: 550 | raise 551 | 552 | brokerid_tag = invstmtrs_ofx.find('brokerid') 553 | if hasattr(brokerid_tag, 'contents'): 554 | try: 555 | account.brokerid = brokerid_tag.contents[0].strip() 556 | except IndexError: 557 | account.warnings.append( 558 | six.u("Empty brokerid tag for %s") % invstmtrs_ofx) 559 | if cls.fail_fast: 560 | raise 561 | 562 | account.type = AccountType.Investment 563 | 564 | if invstmtrs_ofx: 565 | account.statement = cls.parseInvestmentStatement( 566 | invstmtrs_ofx) 567 | ret.append(account) 568 | return ret 569 | 570 | @classmethod 571 | def parseSeclist(cls, seclist_ofx): 572 | securityList = [] 573 | for secinfo_ofx in seclist_ofx.findAll('secinfo'): 574 | uniqueid_tag = secinfo_ofx.find('uniqueid') 575 | name_tag = secinfo_ofx.find('secname') 576 | ticker_tag = secinfo_ofx.find('ticker') 577 | memo_tag = secinfo_ofx.find('memo') 578 | if uniqueid_tag and name_tag: 579 | try: 580 | ticker = ticker_tag.contents[0].strip() 581 | except AttributeError: 582 | # ticker can be empty 583 | ticker = None 584 | try: 585 | memo = memo_tag.contents[0].strip() 586 | except AttributeError: 587 | # memo can be empty 588 | memo = None 589 | securityList.append( 590 | Security(uniqueid_tag.contents[0].strip(), 591 | name_tag.contents[0].strip(), 592 | ticker, 593 | memo)) 594 | return securityList 595 | 596 | @classmethod 597 | def parseInvestmentPosition(cls, ofx): 598 | position = Position() 599 | tag = ofx.find('uniqueid') 600 | if hasattr(tag, 'contents'): 601 | position.security = tag.contents[0].strip() 602 | tag = ofx.find('units') 603 | if hasattr(tag, 'contents'): 604 | position.units = cls.toDecimal(tag) 605 | tag = ofx.find('unitprice') 606 | if hasattr(tag, 'contents'): 607 | position.unit_price = cls.toDecimal(tag) 608 | tag = ofx.find('mktval') 609 | if hasattr(tag, 'contents'): 610 | position.market_value = cls.toDecimal(tag) 611 | tag = ofx.find('dtpriceasof') 612 | if hasattr(tag, 'contents'): 613 | try: 614 | position.date = cls.parseOfxDateTime(tag.contents[0].strip()) 615 | except ValueError: 616 | raise 617 | return position 618 | 619 | @classmethod 620 | def parseInvestmentTransaction(cls, ofx): 621 | transaction = InvestmentTransaction(ofx.name) 622 | tag = ofx.find('fitid') 623 | if hasattr(tag, 'contents'): 624 | transaction.id = tag.contents[0].strip() 625 | tag = ofx.find('memo') 626 | if hasattr(tag, 'contents'): 627 | transaction.memo = tag.contents[0].strip() 628 | tag = ofx.find('dttrade') 629 | if hasattr(tag, 'contents'): 630 | try: 631 | transaction.tradeDate = cls.parseOfxDateTime( 632 | tag.contents[0].strip()) 633 | except ValueError: 634 | raise 635 | tag = ofx.find('dtsettle') 636 | if hasattr(tag, 'contents'): 637 | try: 638 | transaction.settleDate = cls.parseOfxDateTime( 639 | tag.contents[0].strip()) 640 | except ValueError: 641 | raise 642 | tag = ofx.find('uniqueid') 643 | if hasattr(tag, 'contents'): 644 | transaction.security = tag.contents[0].strip() 645 | tag = ofx.find('incometype') 646 | if hasattr(tag, 'contents'): 647 | transaction.income_type = tag.contents[0].strip() 648 | tag = ofx.find('units') 649 | if hasattr(tag, 'contents'): 650 | transaction.units = cls.toDecimal(tag) 651 | tag = ofx.find('unitprice') 652 | if hasattr(tag, 'contents'): 653 | transaction.unit_price = cls.toDecimal(tag) 654 | tag = ofx.find('commission') 655 | if hasattr(tag, 'contents'): 656 | transaction.commission = cls.toDecimal(tag) 657 | tag = ofx.find('fees') 658 | if hasattr(tag, 'contents'): 659 | transaction.fees = cls.toDecimal(tag) 660 | tag = ofx.find('total') 661 | if hasattr(tag, 'contents'): 662 | transaction.total = cls.toDecimal(tag) 663 | tag = ofx.find('inv401ksource') 664 | if hasattr(tag, 'contents'): 665 | transaction.inv401ksource = tag.contents[0].strip() 666 | tag = ofx.find('tferaction') 667 | if hasattr(tag, 'contents'): 668 | transaction.tferaction = tag.contents[0].strip() 669 | return transaction 670 | 671 | @classmethod 672 | def parseInvestmentStatement(cls, invstmtrs_ofx): 673 | statement = InvestmentStatement() 674 | currency_tag = invstmtrs_ofx.find('curdef') 675 | if hasattr(currency_tag, "contents"): 676 | statement.currency = currency_tag.contents[0].strip().lower() 677 | invtranlist_ofx = invstmtrs_ofx.find('invtranlist') 678 | if invtranlist_ofx is not None: 679 | tag = invtranlist_ofx.find('dtstart') 680 | if hasattr(tag, 'contents'): 681 | try: 682 | statement.start_date = cls.parseOfxDateTime( 683 | tag.contents[0].strip()) 684 | except IndexError: 685 | statement.warnings.append(six.u('Empty start date.')) 686 | if cls.fail_fast: 687 | raise 688 | except ValueError: 689 | e = sys.exc_info()[1] 690 | statement.warnings.append(six.u('Invalid start date:\ 691 | %s') % e) 692 | if cls.fail_fast: 693 | raise 694 | 695 | tag = invtranlist_ofx.find('dtend') 696 | if hasattr(tag, 'contents'): 697 | try: 698 | statement.end_date = cls.parseOfxDateTime( 699 | tag.contents[0].strip()) 700 | except IndexError: 701 | statement.warnings.append(six.u('Empty end date.')) 702 | except ValueError: 703 | e = sys.exc_info()[1] 704 | statement.warnings.append(six.u('Invalid end date: \ 705 | %s') % e) 706 | if cls.fail_fast: 707 | raise 708 | 709 | for transaction_type in ['posmf', 'posstock', 'posopt', 'posother', 710 | 'posdebt']: 711 | try: 712 | for investment_ofx in invstmtrs_ofx.findAll(transaction_type): 713 | statement.positions.append( 714 | cls.parseInvestmentPosition(investment_ofx)) 715 | except (ValueError, IndexError, decimal.InvalidOperation, 716 | TypeError): 717 | e = sys.exc_info()[1] 718 | if cls.fail_fast: 719 | raise 720 | statement.discarded_entries.append( 721 | {six.u('error'): six.u("Error parsing positions: \ 722 | ") + str(e), six.u('content'): investment_ofx} 723 | ) 724 | 725 | for transaction_type in InvestmentTransaction.AGGREGATE_TYPES: 726 | try: 727 | for investment_ofx in invstmtrs_ofx.findAll(transaction_type): 728 | statement.transactions.append( 729 | cls.parseInvestmentTransaction(investment_ofx)) 730 | except (ValueError, IndexError, decimal.InvalidOperation): 731 | e = sys.exc_info()[1] 732 | if cls.fail_fast: 733 | raise 734 | statement.discarded_entries.append( 735 | {six.u('error'): transaction_type + ": " + str(e), 736 | six.u('content'): investment_ofx} 737 | ) 738 | 739 | for transaction_ofx in invstmtrs_ofx.findAll('invbanktran'): 740 | for stmt_ofx in transaction_ofx.findAll('stmttrn'): 741 | try: 742 | statement.transactions.append( 743 | cls.parseTransaction(stmt_ofx)) 744 | except OfxParserException: 745 | ofxError = sys.exc_info()[1] 746 | statement.discarded_entries.append( 747 | {'error': str(ofxError), 'content': transaction_ofx}) 748 | if cls.fail_fast: 749 | raise 750 | 751 | invbal_ofx = invstmtrs_ofx.find('invbal') 752 | if invbal_ofx is not None: 753 | # 18073.98+00000000000.00+00000000000.00+00000000000.00 754 | availcash_ofx = invbal_ofx.find('availcash') 755 | if availcash_ofx is not None: 756 | statement.available_cash = cls.toDecimal(availcash_ofx) 757 | margin_balance_ofx = invbal_ofx.find('marginbalance') 758 | if margin_balance_ofx is not None: 759 | statement.margin_balance = cls.toDecimal(margin_balance_ofx) 760 | short_balance_ofx = invbal_ofx.find('shortbalance') 761 | if short_balance_ofx is not None: 762 | statement.short_balance = cls.toDecimal(short_balance_ofx) 763 | buy_power_ofx = invbal_ofx.find('buypower') 764 | if buy_power_ofx is not None: 765 | statement.buy_power = cls.toDecimal(buy_power_ofx) 766 | 767 | ballist_ofx = invbal_ofx.find('ballist') 768 | if ballist_ofx is not None: 769 | statement.balance_list = [] 770 | for balance_ofx in ballist_ofx.findAll('bal'): 771 | brokerage_balance = BrokerageBalance() 772 | name_ofx = balance_ofx.find('name') 773 | if name_ofx is not None: 774 | brokerage_balance.name = name_ofx.contents[0].strip() 775 | description_ofx = balance_ofx.find('desc') 776 | if description_ofx is not None: 777 | brokerage_balance.description = \ 778 | description_ofx.contents[0].strip() 779 | value_ofx = balance_ofx.find('value') 780 | if value_ofx is not None: 781 | brokerage_balance.value = cls.toDecimal(value_ofx) 782 | statement.balance_list.append(brokerage_balance) 783 | 784 | return statement 785 | 786 | @classmethod 787 | def parseOrg(cls, fi_ofx): 788 | institution = Institution() 789 | org = fi_ofx.find('org') 790 | if hasattr(org, 'contents'): 791 | institution.organization = org.contents[0].strip() 792 | 793 | fid = fi_ofx.find('fid') 794 | if hasattr(fid, 'contents'): 795 | institution.fid = fid.contents[0].strip() 796 | 797 | return institution 798 | 799 | @classmethod 800 | def parseSonrs(cls, sonrs): 801 | 802 | items = [ 803 | 'code', 804 | 'severity', 805 | 'dtserver', 806 | 'language', 807 | 'dtprofup', 808 | 'org', 809 | 'fid', 810 | 'intu.bid', 811 | 'message' 812 | ] 813 | idict = {} 814 | for i in items: 815 | try: 816 | idict[i] = sonrs.find(i).contents[0].strip() 817 | except Exception: 818 | idict[i] = None 819 | idict['code'] = int(idict['code']) 820 | if idict['message'] is None: 821 | idict['message'] = '' 822 | 823 | return Signon(idict) 824 | 825 | @classmethod 826 | def parseStmtrs(cls, stmtrs_list, accountType): 827 | ''' Parse the tags and return a list of Accounts object. ''' 828 | ret = [] 829 | for stmtrs_ofx in stmtrs_list: 830 | account = Account() 831 | act_curdef = stmtrs_ofx.find('curdef') 832 | if act_curdef and act_curdef.contents: 833 | account.curdef = act_curdef.contents[0].strip() 834 | acctid_tag = stmtrs_ofx.find('acctid') 835 | if acctid_tag and acctid_tag.contents: 836 | account.account_id = acctid_tag.contents[0].strip() 837 | bankid_tag = stmtrs_ofx.find('bankid') 838 | if bankid_tag and bankid_tag.contents: 839 | account.routing_number = bankid_tag.contents[0].strip() 840 | branchid_tag = stmtrs_ofx.find('branchid') 841 | if branchid_tag and branchid_tag.contents: 842 | account.branch_id = branchid_tag.contents[0].strip() 843 | type_tag = stmtrs_ofx.find('accttype') 844 | if type_tag and type_tag.contents: 845 | account.account_type = type_tag.contents[0].strip() 846 | account.type = accountType 847 | 848 | if stmtrs_ofx: 849 | account.statement = cls.parseStatement(stmtrs_ofx) 850 | ret.append(account) 851 | return ret 852 | 853 | @classmethod 854 | def parseBalance(cls, statement, stmt_ofx, bal_tag_name, bal_attr, 855 | bal_date_attr, bal_type_string): 856 | bal_tag = stmt_ofx.find(bal_tag_name) 857 | if hasattr(bal_tag, "contents"): 858 | balamt_tag = bal_tag.find('balamt') 859 | dtasof_tag = bal_tag.find('dtasof') 860 | if hasattr(balamt_tag, "contents"): 861 | try: 862 | setattr(statement, bal_attr, cls.toDecimal(balamt_tag)) 863 | except (IndexError, decimal.InvalidOperation): 864 | statement.warnings.append( 865 | six.u("%s balance amount was empty for \ 866 | %s") % (bal_type_string, stmt_ofx)) 867 | if cls.fail_fast: 868 | raise OfxParserException("Empty %s balance\ 869 | " % bal_type_string) 870 | if hasattr(dtasof_tag, "contents"): 871 | try: 872 | setattr(statement, bal_date_attr, cls.parseOfxDateTime( 873 | dtasof_tag.contents[0].strip())) 874 | except IndexError: 875 | statement.warnings.append( 876 | six.u("%s balance date was empty for %s\ 877 | ") % (bal_type_string, stmt_ofx)) 878 | if cls.fail_fast: 879 | raise 880 | except ValueError: 881 | statement.warnings.append( 882 | six.u("%s balance date was not allowed for \ 883 | %s") % (bal_type_string, stmt_ofx)) 884 | if cls.fail_fast: 885 | raise 886 | 887 | @classmethod 888 | def parseStatement(cls, stmt_ofx): 889 | ''' 890 | Parse a statement in ofx-land and return a Statement object. 891 | ''' 892 | statement = Statement() 893 | dtstart_tag = stmt_ofx.find('dtstart') 894 | if hasattr(dtstart_tag, "contents"): 895 | try: 896 | statement.start_date = cls.parseOfxDateTime( 897 | dtstart_tag.contents[0].strip()) 898 | except IndexError: 899 | statement.warnings.append( 900 | six.u("Statement start date was empty for %s") % stmt_ofx) 901 | if cls.fail_fast: 902 | raise 903 | except ValueError: 904 | statement.warnings.append( 905 | six.u("Statement start date was not allowed for \ 906 | %s") % stmt_ofx) 907 | if cls.fail_fast: 908 | raise 909 | 910 | dtend_tag = stmt_ofx.find('dtend') 911 | if hasattr(dtend_tag, "contents"): 912 | try: 913 | statement.end_date = cls.parseOfxDateTime( 914 | dtend_tag.contents[0].strip()) 915 | except IndexError: 916 | statement.warnings.append( 917 | six.u("Statement start date was empty for %s") % stmt_ofx) 918 | if cls.fail_fast: 919 | raise 920 | except ValueError: 921 | msg = six.u("Statement start date was not formatted " 922 | "correctly for %s") 923 | statement.warnings.append(msg % stmt_ofx) 924 | if cls.fail_fast: 925 | raise 926 | except TypeError: 927 | statement.warnings.append( 928 | six.u("Statement start date was not allowed for \ 929 | %s") % stmt_ofx) 930 | if cls.fail_fast: 931 | raise 932 | 933 | currency_tag = stmt_ofx.find('curdef') 934 | if hasattr(currency_tag, "contents"): 935 | try: 936 | statement.currency = currency_tag.contents[0].strip().lower() 937 | except IndexError: 938 | statement.warnings.append( 939 | six.u("Currency definition was empty for %s") % stmt_ofx) 940 | if cls.fail_fast: 941 | raise 942 | 943 | cls.parseBalance(statement, stmt_ofx, 'ledgerbal', 944 | 'balance', 'balance_date', 'ledger') 945 | 946 | cls.parseBalance(statement, stmt_ofx, 'availbal', 'available_balance', 947 | 'available_balance_date', 'ledger') 948 | 949 | for transaction_ofx in stmt_ofx.findAll('stmttrn'): 950 | try: 951 | statement.transactions.append( 952 | cls.parseTransaction(transaction_ofx)) 953 | except OfxParserException: 954 | ofxError = sys.exc_info()[1] 955 | statement.discarded_entries.append( 956 | {'error': str(ofxError), 'content': transaction_ofx}) 957 | if cls.fail_fast: 958 | raise 959 | 960 | return statement 961 | 962 | @classmethod 963 | def parseTransaction(cls, txn_ofx): 964 | ''' 965 | Parse a transaction in ofx-land and return a Transaction object. 966 | ''' 967 | transaction = Transaction() 968 | 969 | type_tag = txn_ofx.find('trntype') 970 | if hasattr(type_tag, 'contents'): 971 | try: 972 | transaction.type = type_tag.contents[0].lower().strip() 973 | except IndexError: 974 | raise OfxParserException(six.u("Empty transaction type")) 975 | except TypeError: 976 | raise OfxParserException( 977 | six.u("No Transaction type (a required field)")) 978 | 979 | name_tag = txn_ofx.find('name') 980 | if hasattr(name_tag, "contents"): 981 | try: 982 | transaction.payee = name_tag.contents[0].strip() 983 | except IndexError: 984 | raise OfxParserException(six.u("Empty transaction name")) 985 | except TypeError: 986 | raise OfxParserException( 987 | six.u("No Transaction name (a required field)")) 988 | 989 | memo_tag = txn_ofx.find('memo') 990 | if hasattr(memo_tag, "contents"): 991 | try: 992 | transaction.memo = memo_tag.contents[0].strip() 993 | except IndexError: 994 | # Memo can be empty. 995 | pass 996 | except TypeError: 997 | pass 998 | 999 | amt_tag = txn_ofx.find('trnamt') 1000 | if hasattr(amt_tag, "contents"): 1001 | try: 1002 | transaction.amount = cls.toDecimal(amt_tag) 1003 | except IndexError: 1004 | raise OfxParserException("Invalid Transaction Date") 1005 | except decimal.InvalidOperation: 1006 | # Some banks use a null transaction for including interest 1007 | # rate changes on your statement. 1008 | if amt_tag.contents[0].strip() in ('null', '-null'): 1009 | transaction.amount = 0 1010 | else: 1011 | raise OfxParserException( 1012 | six.u("Invalid Transaction Amount: '%s'") % amt_tag.contents[0]) 1013 | except TypeError: 1014 | raise OfxParserException( 1015 | six.u("No Transaction Amount (a required field)")) 1016 | else: 1017 | raise OfxParserException( 1018 | six.u("Missing Transaction Amount (a required field)")) 1019 | 1020 | date_tag = txn_ofx.find('dtposted') 1021 | if hasattr(date_tag, "contents"): 1022 | try: 1023 | transaction.date = cls.parseOfxDateTime( 1024 | date_tag.contents[0].strip()) 1025 | except IndexError: 1026 | raise OfxParserException("Invalid Transaction Date") 1027 | except ValueError: 1028 | ve = sys.exc_info()[1] 1029 | raise OfxParserException(str(ve)) 1030 | except TypeError: 1031 | raise OfxParserException( 1032 | six.u("No Transaction Date (a required field)")) 1033 | else: 1034 | raise OfxParserException( 1035 | six.u("Missing Transaction Date (a required field)")) 1036 | 1037 | user_date_tag = txn_ofx.find('dtuser') 1038 | if hasattr(user_date_tag, "contents"): 1039 | try: 1040 | transaction.user_date = cls.parseOfxDateTime( 1041 | user_date_tag.contents[0].strip()) 1042 | except IndexError: 1043 | raise OfxParserException("Invalid Transaction User Date") 1044 | except ValueError: 1045 | ve = sys.exc_info()[1] 1046 | raise OfxParserException(str(ve)) 1047 | except TypeError: 1048 | pass 1049 | 1050 | id_tag = txn_ofx.find('fitid') 1051 | if hasattr(id_tag, "contents"): 1052 | try: 1053 | transaction.id = id_tag.contents[0].strip() 1054 | except IndexError: 1055 | raise OfxParserException(six.u("Empty FIT id (a required \ 1056 | field)")) 1057 | except TypeError: 1058 | raise OfxParserException(six.u("No FIT id (a required field)")) 1059 | else: 1060 | raise OfxParserException(six.u("Missing FIT id (a required \ 1061 | field)")) 1062 | 1063 | sic_tag = txn_ofx.find('sic') 1064 | if hasattr(sic_tag, 'contents'): 1065 | try: 1066 | transaction.sic = sic_tag.contents[0].strip() 1067 | except IndexError: 1068 | raise OfxParserException(six.u("Empty transaction Standard \ 1069 | Industry Code (SIC)")) 1070 | 1071 | if transaction.sic is not None and transaction.sic in mcc.codes: 1072 | try: 1073 | transaction.mcc = mcc.codes.get(transaction.sic, '').get('combined \ 1074 | description') 1075 | except IndexError: 1076 | raise OfxParserException(six.u("Empty transaction Merchant Category \ 1077 | Code (MCC)")) 1078 | except AttributeError: 1079 | if cls.fail_fast: 1080 | raise 1081 | 1082 | checknum_tag = txn_ofx.find('checknum') 1083 | if hasattr(checknum_tag, 'contents'): 1084 | try: 1085 | transaction.checknum = checknum_tag.contents[0].strip() 1086 | except IndexError: 1087 | raise OfxParserException(six.u("Empty Check (or other reference) \ 1088 | number")) 1089 | 1090 | return transaction 1091 | 1092 | @classmethod 1093 | def toDecimal(cls, tag): 1094 | d = tag.contents[0].strip() 1095 | # Handle 10,000.50 formatted numbers 1096 | if re.search(r'.*\..*,', d): 1097 | d = d.replace('.', '') 1098 | # Handle 10.000,50 formatted numbers 1099 | if re.search(r'.*,.*\.', d): 1100 | d = d.replace(',', '') 1101 | # Handle 10000,50 formatted numbers 1102 | if '.' not in d and ',' in d: 1103 | d = d.replace(',', '.') 1104 | # Handle 1 025,53 formatted numbers 1105 | d = d.replace(' ', '') 1106 | # Handle +1058,53 formatted numbers 1107 | d = d.replace('+', '') 1108 | return decimal.Decimal(d) 1109 | --------------------------------------------------------------------------------