├── 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%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(["%s>" % 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] + "" + \
177 | heirarchy.pop() + ">"
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] + "" + \
194 | heirarchy.pop() + ">"
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)([a-z0-9_\.]+)>',
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)(?[a-z0-9_\.]+>)', ofx_string)
170 | new_fh = StringIO()
171 | for token in tokens:
172 | is_closing_tag = token.startswith('')
173 | is_processing_tag = token.startswith('')
174 | is_cdata = 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 |
--------------------------------------------------------------------------------