├── fixofx ├── __init__.py ├── test │ ├── __init__.py │ ├── test_util.py │ ├── test_ofx_error.py │ ├── test_ofx_document.py │ ├── ofx_test_utils.py │ ├── test_ofc_converter.py │ ├── test_ofc_parser.py │ ├── test_mock_ofx_server.py │ ├── fixtures │ │ ├── blank_memo.ofx │ │ ├── ofc_with_chknum.ofc │ │ ├── nobankinfo_and_trnrs.ofc │ │ ├── invalid_blank_tag_ledger.ofc │ │ ├── bad.ofc │ │ └── empty_tags.ofx │ ├── test_ofx_response.py │ ├── test_ofx_parser.py │ ├── test_ofx_account.py │ ├── test_ofx_client.py │ ├── test_ofx_request.py │ ├── test_ofx_builder.py │ ├── test_ofx_validators.py │ └── test_ofxtools_qif_converter.py ├── ofxtools │ ├── util.py │ ├── __init__.py │ ├── ofc_parser.py │ ├── qif_parser.py │ ├── ofc_converter.py │ └── ofx_statement.py └── ofx │ ├── __init__.py │ ├── institution.py │ ├── document.py │ ├── validators.py │ ├── account.py │ ├── filetyper.py │ ├── error.py │ ├── parser.py │ ├── request.py │ ├── generator.py │ ├── response.py │ ├── client.py │ └── builder.py ├── pytest.ini ├── .gitignore ├── requirements.txt ├── requirements-dev.txt ├── .travis.yml ├── MANIFEST.in ├── setup.py ├── bin ├── ofxfake.py └── ofxfix.py └── README.rst /fixofx/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | -------------------------------------------------------------------------------- /fixofx/test/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_paths = . 3 | 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | *.swo 4 | __pycache__ 5 | .idea 6 | dist 7 | *.egg-info 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyparsing==2.0.2 2 | python-dateutil==2.2 3 | six==1.7.3 4 | wsgi-intercept==0.6.5 5 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pytest==2.5.2 3 | pytest-pythonpath==0.3 4 | ipdb==0.8 5 | ipython==2.1.0 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.4" 4 | install: 5 | - pip install -r requirements-dev.txt 6 | script: 7 | - py.test -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | include MANIFEST.in 4 | include requirements.txt 5 | recursive-exclude * __pycache__ 6 | recursive-exclude * *.py[co] 7 | -------------------------------------------------------------------------------- /fixofx/ofxtools/util.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | import re 3 | 4 | def strip_empty_tags(ofx): 5 | """Strips open/close tags that have no content.""" 6 | strip_search = '<(?P[^>]+)>\s*' 7 | return re.sub(strip_search, '', ofx) 8 | 9 | -------------------------------------------------------------------------------- /fixofx/test/test_util.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | import re 3 | from os.path import join, realpath, dirname 4 | import unittest 5 | 6 | from fixofx.ofxtools.util import strip_empty_tags 7 | 8 | 9 | class StripEmptyTags(unittest.TestCase): 10 | def test_strip_empty_tags(self): 11 | with open(join(realpath(dirname(__file__)), 'fixtures', 'empty_tags.ofx'), 'r') as f: 12 | empty_tags_file = f.read() 13 | empty_tag_pattern = '<(?P[^>]+)>\s*' 14 | 15 | result = strip_empty_tags(empty_tags_file) 16 | self.assertFalse(re.match(empty_tag_pattern, result)) 17 | 18 | 19 | if __name__ == '__main__': 20 | unittest.main() 21 | -------------------------------------------------------------------------------- /fixofx/test/test_ofx_error.py: -------------------------------------------------------------------------------- 1 | # Copyright 2005-2010 Wesabe, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import unittest 15 | 16 | from fixofx.ofx import Error 17 | 18 | 19 | class ErrorTests(unittest.TestCase): 20 | def test_ofx_error_to_str(self): 21 | error = Error("test", code=9999, severity="ERROR", message="Test") 22 | expected = "Test\n(ERROR 9999: Unknown error code)" 23 | self.assertEqual(expected, error.str()) 24 | self.assertEqual(expected, str(error)) 25 | 26 | 27 | if __name__ == '__main__': 28 | unittest.main() 29 | -------------------------------------------------------------------------------- /fixofx/test/test_ofx_document.py: -------------------------------------------------------------------------------- 1 | # Copyright 2005-2010 Wesabe, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import unittest 15 | 16 | from fixofx.ofx import Response 17 | from fixofx.test.ofx_test_utils import get_checking_stmt 18 | 19 | 20 | class DocumentTests(unittest.TestCase): 21 | def setUp(self): 22 | self.checking = get_checking_stmt() 23 | 24 | def test_statement_as_xml(self): 25 | response = Response(self.checking) 26 | self.assertEqual(' %s" % (expr, str(toks.asList()))) 34 | 35 | def _ofxtoolsExceptionDebugAction( instring, loc, expr, exc ): 36 | sys.stderr.write("Exception raised: %s" % exc) 37 | -------------------------------------------------------------------------------- /fixofx/test/test_ofc_converter.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | from os.path import join, realpath, dirname 3 | import unittest 4 | 5 | from fixofx.ofxtools.ofc_converter import OfcConverter 6 | 7 | no_bankinfo_ofc_path = join(realpath(dirname(__file__)), 'fixtures', 'nobankinfo_and_trnrs.ofc') 8 | 9 | def assert_not_raises(function, param, exception): 10 | try: 11 | function(param) 12 | except exception: 13 | raise AssertionError("Exception %s raised" %exception) 14 | 15 | 16 | class OFCConverterWithNoBankInfoTestCase(unittest.TestCase): 17 | """ 18 | Testing an special case that doesn't has the bank info 'tags' 19 | and instead of having ACCTSTMT, it has TRNRS which has the same 20 | information needed 21 | """ 22 | 23 | def setUp(self): 24 | with open(no_bankinfo_ofc_path, 'r') as f: 25 | self.ofc = f.read() 26 | 27 | def test_converting_ofc_with_no_bankinfo_should_not_raise_KeyError(self): 28 | assert OfcConverter(self.ofc) 29 | 30 | def test_ofc_converter_getting_balance_value(self): 31 | ofc_converter = OfcConverter(self.ofc) 32 | self.assertEqual(ofc_converter.balance, '350.66') 33 | 34 | def test_ofc_converter_getting_start_date(self): 35 | ofc_converter = OfcConverter(self.ofc) 36 | self.assertEqual(ofc_converter.start_date, '20101214') 37 | 38 | def test_ofc_converter_getting_end_date(self): 39 | ofc_converter = OfcConverter(self.ofc) 40 | self.assertEqual(ofc_converter.end_date, '20110113') 41 | 42 | if __name__ == '__main__': 43 | unittest.main() 44 | -------------------------------------------------------------------------------- /fixofx/ofx/institution.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | # Copyright 2005-2010 Wesabe, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # 17 | # ofx.institution - container for financial insitution configuration data. 18 | # 19 | 20 | # REVIEW: Well, this certainly doesn't do much. 21 | # At this point it works fine as a data structure. Later on 22 | # it would be nice if it actually, you know, did something. 23 | 24 | 25 | class Institution: 26 | def __init__(self, name="", ofx_org="", ofx_url="", ofx_fid=""): 27 | self.name = name 28 | self.ofx_org = ofx_org 29 | self.ofx_url = ofx_url 30 | self.ofx_fid = ofx_fid 31 | 32 | def to_s(self): 33 | return ("Name: %s; Org: %s; OFX URL: %s; FID: %s") % \ 34 | (self.name, self.ofx_org, self.ofx_url, self.ofx_fid) 35 | 36 | def __repr__(self): 37 | return self.to_s() 38 | 39 | def as_dict(self): 40 | return { 'name' : self.name, 41 | 'ofx_org' : self.ofx_org, 42 | 'ofx_url' : self.ofx_url, 43 | 'ofx_fid' : self.ofx_fid } 44 | 45 | def load_from_dict(fi_dict): 46 | return Institution(name=fi_dict.get('name'), 47 | ofx_org=fi_dict.get('ofx_org'), 48 | ofx_url=fi_dict.get('ofx_url'), 49 | ofx_fid=fi_dict.get('ofx_fid')) 50 | load_from_dict = staticmethod(load_from_dict) 51 | 52 | -------------------------------------------------------------------------------- /fixofx/test/test_ofc_parser.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | from os.path import join, realpath, dirname 3 | import unittest 4 | 5 | from pyparsing import ParseException 6 | 7 | from fixofx.ofxtools.ofc_parser import OfcParser 8 | 9 | 10 | FIXTURES_PATH = join(realpath(dirname(__file__)), 'fixtures') 11 | 12 | def assert_not_raises(function, param, exception): 13 | try: 14 | function(param) 15 | except exception: 16 | raise AssertionError("Exception %s raised" %exception) 17 | 18 | 19 | def read_file(filename): 20 | with open(join(FIXTURES_PATH, filename), 'r') as f: 21 | return f.read() 22 | 23 | 24 | class OFCParserTestCase(unittest.TestCase): 25 | 26 | def setUp(self): 27 | self.ofc = read_file('bad.ofc') 28 | self.parser = OfcParser() 29 | 30 | def test_parsing_bad_ofc_should_not_raise_exception(self): 31 | assert_not_raises(self.parser.parse, self.ofc, ParseException) 32 | 33 | def test_parsing_ofc_with_blank_ledger_tag_not_raise_Exception(self): 34 | self.ofc = read_file('invalid_blank_tag_ledger.ofc') 35 | assert_not_raises(self.parser.parse, self.ofc, Exception) 36 | 37 | def test_parsing_ofc_without_bank_info_not_raise_Exception(self): 38 | self.ofc = read_file('nobankinfo_and_trnrs.ofc') 39 | assert_not_raises(self.parser.parse, self.ofc, Exception) 40 | 41 | def test_chknum_to_checknum_translation(self): 42 | self.ofc = read_file('ofc_with_chknum.ofc') 43 | #ensure that the CHECKNUM was translated 44 | self.assertTrue('CHECKNUM' in str(self.parser._translate_chknum_to_checknum(self.ofc))) 45 | self.assertFalse('CHKNUM' in str(self.parser._translate_chknum_to_checknum(self.ofc))) 46 | 47 | def test_not_crashes_when_an_OFC_has_empty_tags(self): 48 | ofc = read_file('empty_tags.ofx')#it is an ofc by inside 49 | assert_not_raises(self.parser.parse, ofc, ParseException) 50 | 51 | def test_not_exceed_max_recursion_limit(self): 52 | """ 53 | For some reason, this file exceeds the normal recursion_limit 54 | i've solved this setting the max recursion depth. 55 | """ 56 | ofc = read_file('recursion_depth_exceeded.ofx') 57 | assert_not_raises(self.parser.parse, ofc, RuntimeError) 58 | 59 | 60 | if __name__ == '__main__': 61 | unittest.main() 62 | -------------------------------------------------------------------------------- /fixofx/test/test_mock_ofx_server.py: -------------------------------------------------------------------------------- 1 | # Copyright 2005-2010 Wesabe, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # 16 | # MockOfxServer - simple mock server for testing 17 | # 18 | import urllib.request, urllib.error, urllib.parse 19 | from wsgi_intercept.urllib_intercept import install_opener 20 | import wsgi_intercept 21 | 22 | from fixofx.test.ofx_test_utils import get_creditcard_stmt, get_savings_stmt, get_checking_stmt 23 | 24 | 25 | class MockOfxServer: 26 | def __init__(self, port=9876): 27 | install_opener() 28 | wsgi_intercept.add_wsgi_intercept('localhost', port, self.interceptor) 29 | 30 | def handleResponse(self, environment, start_response): 31 | status = "200 OK" 32 | headers = [('Content-Type', 'application/ofx')] 33 | start_response(status, headers) 34 | if "wsgi.input" in environment: 35 | request_body = environment["wsgi.input"].read() 36 | 37 | if request_body.find("CHECKING".encode('utf-8')) != -1: 38 | return [get_checking_stmt()] 39 | elif request_body.find("SAVINGS".encode('utf-8')) != -1: 40 | return [get_savings_stmt()] 41 | else: 42 | return [get_creditcard_stmt()] 43 | else: 44 | return [get_creditcard_stmt()] 45 | 46 | def interceptor(self): 47 | return self.handleResponse 48 | 49 | import unittest 50 | 51 | class MockOfxServerTest(unittest.TestCase): 52 | def setUp(self): 53 | self.server = MockOfxServer() 54 | self.success = get_creditcard_stmt() 55 | 56 | def test_simple_get(self): 57 | result = urllib.request.urlopen('http://localhost:9876/') 58 | self.assertEqual(result.read(), self.success) 59 | 60 | if __name__ == "__main__": 61 | unittest.main() 62 | -------------------------------------------------------------------------------- /fixofx/test/fixtures/blank_memo.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 | 20140110235959 19 | POR 20 | 21 | 22 | 23 | 24 | 1001 25 | 26 | 0 27 | INFO 28 | 29 | 30 | BRL 31 | 32 | 0341 33 | 5082038208 34 | CHECKING 35 | 36 | 37 | 20140108 38 | 20140110 39 | 40 | DEBIT 41 | 20140108 42 | -4925.00 43 | 14010801 44 | 14010801 45 | FIRSTCOM 46 | 47 | 48 | DEBIT 49 | 20140108 50 | -1497.20 51 | 14010802 52 | 14010802 53 | PERSONNEL SUPPORT 54 | 55 | 56 | DEBIT 57 | 20140110 58 | -12000.00 59 | 14011001 60 | 14011001 61 | LINCOLN FURLAN ANDO LINCOLN 62 | 63 | 64 | DEBIT 65 | 20140110 66 | -1178.90 67 | 14011002 68 | 14011002 69 | NUNES E SAWAYA 70 | 71 | 72 | DEBIT 73 | 20140110 74 | -613.95 75 | 14011003 76 | 14011003 77 | NUNES E SAWAYA 78 | 79 | 80 | DEBIT 81 | 20140117 82 | -722.82 83 | 14011701 84 | 14011701 85 | 86 | 87 | 88 | DEBIT 89 | 20140117 90 | -3723.90 91 | 14011702 92 | 14011702 93 | DAVID BRENER 94 | 95 | 96 | DEBIT 97 | 20140117 98 | -464.80 99 | 14011703 100 | 14011703 101 | REEMBOLSO 102 | 103 | 104 | DEBIT 105 | 20140117 106 | -232.91 107 | 14011704 108 | 14011704 109 | NUNES E SAWAYA 110 | 111 | 112 | DEBIT 113 | 20140110 114 | -23.26 115 | 14011001 116 | 14011001 117 | ISS 118 | 119 | 120 | 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /fixofx/test/test_ofx_response.py: -------------------------------------------------------------------------------- 1 | # Copyright 2005-2010 Wesabe, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import unittest 15 | import xml.etree.ElementTree as ElementTree 16 | 17 | 18 | from fixofx.ofx import Response 19 | from fixofx.test.ofx_test_utils import get_checking_stmt 20 | 21 | 22 | class ResponseTests(unittest.TestCase): 23 | def setUp(self): 24 | self.response_text = get_checking_stmt().decode('utf-8') 25 | self.response = Response(get_checking_stmt()) 26 | 27 | def test_signon_success(self): 28 | status = self.response.check_signon_status() 29 | self.assertTrue(status) 30 | 31 | def test_account_list(self): 32 | statements = self.response.get_statements() 33 | self.assertEqual(1, len(statements)) 34 | 35 | for stmt in statements: 36 | self.assertEqual("USD", stmt.get_currency()) 37 | self.assertEqual("20100424", stmt.get_begin_date()) 38 | self.assertEqual("20100723", stmt.get_end_date()) 39 | self.assertEqual("1129.49", stmt.get_balance()) 40 | self.assertEqual("20100723", stmt.get_balance_date()) 41 | 42 | account = stmt.get_account() 43 | self.assertEqual("987987987", account.aba_number) 44 | self.assertEqual("58152460", account.acct_number) 45 | self.assertEqual("CHECKING", account.get_ofx_accttype()) 46 | 47 | def test_as_xml(self): 48 | # First just sanity-check that ElementTree will throw an error 49 | # if given a non-XML document. 50 | try: 51 | response_elem = ElementTree.fromstring(self.response_text) 52 | self.fail("Expected parse exception but did not get one.") 53 | except: 54 | pass 55 | 56 | # Then see if we can get a real parse success, with no ExpatError. 57 | xml = self.response.as_xml() 58 | xml_elem = ElementTree.fromstring(xml) 59 | self.assertTrue(isinstance(xml_elem, ElementTree.Element)) 60 | 61 | # Finally, for kicks, try to get a value out of it. 62 | org_iter = xml_elem.getiterator("ORG") 63 | for org in org_iter: 64 | self.assertEqual("FAKEOFX", org.text) 65 | 66 | if __name__ == '__main__': 67 | unittest.main() 68 | -------------------------------------------------------------------------------- /fixofx/test/test_ofx_parser.py: -------------------------------------------------------------------------------- 1 | # Copyright 2005-2010 Wesabe, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import unittest 15 | 16 | from fixofx.ofx import Parser 17 | from fixofx.test.ofx_test_utils import get_checking_stmt, get_creditcard_stmt, get_blank_memo_stmt 18 | 19 | 20 | class ParserTests(unittest.TestCase): 21 | def setUp(self): 22 | parser = Parser() 23 | checking_stmt = get_checking_stmt() 24 | creditcard_stmt = get_creditcard_stmt() 25 | blank_memo_stmt = get_blank_memo_stmt() 26 | self.checkparse = parser.parse(checking_stmt) 27 | self.creditcardparse = parser.parse(creditcard_stmt) 28 | self.blank_memoparse = parser.parse(blank_memo_stmt) 29 | 30 | def test_successful_parse(self): 31 | """Test parsing a valid OFX document containing a 'success' message.""" 32 | print(list(self.checkparse["body"]["OFX"][0]["SIGNONMSGSRSV1"])) 33 | self.assertEqual("SUCCESS", 34 | self.checkparse["body"]["OFX"]["SIGNONMSGSRSV1"]["SONRS"]["STATUS"]["MESSAGE"]) 35 | 36 | def test_successfull_parse_for_blank_memo(self): 37 | """Test parsing a valid OFX document with blank memo containing a 'success' message.""" 38 | self.assertEqual("INFO", 39 | self.blank_memoparse["body"]["OFX"]["SIGNONMSGSRSV1"]["SONRS"]["STATUS"]["SEVERITY"]) 40 | 41 | def test_body_read(self): 42 | """Test reading a value from deep in the body of the OFX document.""" 43 | self.assertEqual("-5128.16", 44 | self.creditcardparse["body"]["OFX"]["CREDITCARDMSGSRSV1"]["CCSTMTTRNRS"]["CCSTMTRS"]["LEDGERBAL"]["BALAMT"]) 45 | 46 | def test_body_read_for_blank_memo(self): 47 | """Test reading a value from deep in the body of the OFX document.""" 48 | self.assertEqual("-23.26", 49 | self.blank_memoparse["body"]["OFX"]["BANKMSGSRSV1"]["STMTTRNRS"]["STMTRS"]["BANKTRANLIST"]["STMTTRN"]["TRNAMT"]) 50 | 51 | def test_header_read(self): 52 | """Test reading a header from the OFX document.""" 53 | self.assertEqual("100", self.checkparse["header"]["OFXHEADER"]) 54 | 55 | def test_header_read_for_blank_memo(self): 56 | """Test reading a header from the OFX document.""" 57 | self.assertEqual("100", self.blank_memoparse["header"]["OFXHEADER"]) 58 | 59 | 60 | if __name__ == '__main__': 61 | unittest.main() 62 | -------------------------------------------------------------------------------- /fixofx/test/fixtures/ofc_with_chknum.ofc: -------------------------------------------------------------------------------- 1 | 2 | 2 3 | 1252 4 | 5 | 6 | 001 7 | 3071-6 8 | 00000071692 9 | 0 10 | 11 | 12 | 20080630 13 | 20080731 14 | 480.58 15 | 16 | 1 17 | 20080711 18 | -60.57 19 | 20080711160570 20 | 00049434 21 | Pagamento de Telefone 22 | 23 | 24 | 1 25 | 20080714 26 | -147.36 27 | 200807141147360 28 | 32942835 29 | Pagto cartão crédito 30 | 31 | 32 | 1 33 | 20080714 34 | -25.78 35 | 20080714125780 36 | 11058928 37 | Cobrança de Juros 38 | 39 | 40 | 0 41 | 20080715 42 | 1000.00 43 | 2008071501000000 44 | 00972676 45 | DOC Crédito em Conta - 745 0094 7894868736 ALLAN ALBAREZ 46 | 47 | 48 | 1 49 | 20080721 50 | -55.00 51 | 20080721155000 52 | 00028718 53 | Mesada 54 | 55 | 56 | 1 57 | 20080724 58 | -9.00 59 | 2008072419000 60 | 00080724 61 | Tarifa Pacote de Serviços - Tarifa referente a 24/07/2008 62 | 63 | 64 | 1 65 | 20080725 66 | -2.50 67 | 2008072512500 68 | 00080602 69 | Tarifa SMS - Mês Anterior - Tarifa referente a 02/06/2008 70 | 71 | 72 | 1 73 | 20080731 74 | -1.19 75 | 2008073111190 76 | 91100701 77 | Cobrança de I.O.F. 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /fixofx/test/test_ofx_account.py: -------------------------------------------------------------------------------- 1 | # Copyright 2005-2010 Wesabe, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import unittest 15 | 16 | from fixofx.ofx import Institution, Account 17 | 18 | 19 | class AccountTests(unittest.TestCase): 20 | def setUp(self): 21 | self.institution = Institution(name="Test Bank", 22 | ofx_org="Test Bank", 23 | ofx_url="https://ofx.example.com", 24 | ofx_fid="9999999") 25 | self.good_acct = Account(acct_type="CHECKING", 26 | acct_number="1122334455", 27 | aba_number="123456789", 28 | institution=self.institution) 29 | self.bad_acct = Account(acct_type="Fnargle", 30 | acct_number="", aba_number="", 31 | institution=None) 32 | 33 | def test_account_complete(self): 34 | self.assertEqual(self.good_acct.is_complete(), True) 35 | self.assertEqual(self.bad_acct.is_complete(), False) 36 | 37 | def test_as_dict(self): 38 | testdict = self.good_acct.as_dict() 39 | self.assertEqual(testdict["acct_type"], "CHECKING") 40 | self.assertEqual(testdict["acct_number"], "1122334455") 41 | self.assertEqual(testdict["aba_number"], "123456789") 42 | self.assertEqual(testdict["desc"], None) 43 | self.assertEqual(testdict["balance"], None) 44 | 45 | fi_dict = testdict["institution"] 46 | self.assertEqual(fi_dict["name"], "Test Bank") 47 | self.assertEqual(fi_dict["ofx_org"], "Test Bank") 48 | self.assertEqual(fi_dict["ofx_url"], "https://ofx.example.com") 49 | self.assertEqual(fi_dict["ofx_fid"], "9999999") 50 | 51 | def test_load_from_dict(self): 52 | testdict = self.good_acct.as_dict() 53 | new_acct = Account.load_from_dict(testdict) 54 | self.assertEqual(new_acct.acct_type, "CHECKING") 55 | self.assertEqual(new_acct.acct_number, "1122334455") 56 | self.assertEqual(new_acct.aba_number, "123456789") 57 | self.assertEqual(new_acct.desc, None) 58 | self.assertEqual(new_acct.balance, None) 59 | 60 | new_fi = Institution.load_from_dict(testdict['institution']) 61 | self.assertEqual(new_fi.name, "Test Bank") 62 | self.assertEqual(new_fi.ofx_org, "Test Bank") 63 | self.assertEqual(new_fi.ofx_url, "https://ofx.example.com") 64 | self.assertEqual(new_fi.ofx_fid, "9999999") 65 | 66 | 67 | if __name__ == '__main__': 68 | unittest.main() 69 | -------------------------------------------------------------------------------- /fixofx/ofx/document.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | # Copyright 2005-2010 Wesabe, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # 17 | # ofx.document - abstract OFX document. 18 | # 19 | import xml.sax.saxutils as sax 20 | 21 | class Document: 22 | def as_xml(self, original_format=None, date_format=None): 23 | """Formats this document as an OFX 2.0 XML document.""" 24 | xml = "" 25 | 26 | # NOTE: Encoding in OFX, particularly in OFX 1.02, 27 | # is kind of a mess. The OFX 1.02 spec talks about "UNICODE" 28 | # as a supported encoding, which the OFX 2.0 spec has 29 | # back-rationalized to "UTF-8". The "US-ASCII" encoding is 30 | # given as "USASCII". Yet the 1.02 spec acknowledges that 31 | # not everyone speaks English nor uses UNICODE, so they let 32 | # you throw any old encoding in there you'd like. I'm going 33 | # with the idea that if the most common encodings are named 34 | # in an OFX file, they should be translated to "real" XML 35 | # encodings, and if no encoding is given, UTF-8 (which is a 36 | # superset of US-ASCII) should be assumed; but if a named 37 | # encoding other than USASCII or 'UNICODE' is given, that 38 | # should be preserved. I'm also adding a get_encoding() 39 | # method so that we can start to survey what encodings 40 | # we're actually seeing, and use that to maybe be smarter 41 | # about this in the future. 42 | #forcing encoding to utf-8 43 | encoding = "UTF-8" 44 | 45 | xml += """\n""" % encoding 46 | xml += """\n""" % \ 48 | (self.parse_dict["header"]["SECURITY"], 49 | self.parse_dict["header"]["OLDFILEUID"], 50 | self.parse_dict["header"]["NEWFILEUID"]) 51 | 52 | if original_format is not None: 53 | xml += """\n""" % original_format 54 | if date_format is not None: 55 | xml += """\n""" % date_format 56 | 57 | taglist = self.parse_dict["body"]["OFX"][0].asList() 58 | xml += self._format_xml(taglist) 59 | 60 | return xml 61 | 62 | def _format_xml(self, mylist, indent=0): 63 | xml = "" 64 | indentstring = " " * indent 65 | tag = mylist.pop(0) 66 | if len(mylist) > 0 and isinstance(mylist[0], list): 67 | xml += "%s<%s>\n" % (indentstring, tag) 68 | for value in mylist: 69 | xml += self._format_xml(value, indent=indent + 2) 70 | xml += "%s\n" % (indentstring, tag) 71 | elif len(mylist) > 0: 72 | # Unescape then reescape so we don't wind up with '&lt;', oy. 73 | value = sax.escape(sax.unescape(mylist[0])) 74 | xml += "%s<%s>%s\n" % (indentstring, tag, value, tag) 75 | return xml 76 | 77 | -------------------------------------------------------------------------------- /fixofx/ofx/validators.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | # Copyright 2005-2010 Wesabe, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # 17 | # ofx.validators - Classes to validate certain financial data types. 18 | # 19 | 20 | class RoutingNumber: 21 | def __init__(self, number): 22 | self.number = number 23 | # FIXME: need to make sure we're really getting a number and not any non-number characters. 24 | try: 25 | self.digits = [int(digit) for digit in str(self.number).strip()] 26 | self.region_code = int(str(self.digits[0]) + str(self.digits[1])) 27 | self.converted = True 28 | except ValueError: 29 | # Not a number, failed to convert 30 | self.digits = None 31 | self.region_code = None 32 | self.converted = False 33 | 34 | def is_valid(self): 35 | if self.converted is False or len(self.digits) != 9: 36 | return False 37 | 38 | checksum = ((self.digits[0] * 3) + 39 | (self.digits[1] * 7) + 40 | self.digits[2] + 41 | (self.digits[3] * 3) + 42 | (self.digits[4] * 7) + 43 | self.digits[5] + 44 | (self.digits[6] * 3) + 45 | (self.digits[7] * 7) + 46 | self.digits[8] ) 47 | return (checksum % 10 == 0) 48 | 49 | def get_type(self): 50 | # Remember that range() stops one short of the second argument. 51 | # In other words, "x in range(1, 13)" means "x >= 1 and x < 13". 52 | if self.region_code == 0: 53 | return "United States Government" 54 | elif self.region_code in range(1, 13): 55 | return "Primary" 56 | elif self.region_code in range(21, 33): 57 | return "Thrift" 58 | elif self.region_code in range(61, 73): 59 | return "Electronic" 60 | elif self.region_code == 80: 61 | return "Traveller's Cheque" 62 | else: 63 | return None 64 | 65 | def get_region(self): 66 | if self.region_code == 0: 67 | return "United States Government" 68 | elif self.region_code in [1, 21, 61]: 69 | return "Boston" 70 | elif self.region_code in [2, 22, 62]: 71 | return "New York" 72 | elif self.region_code in [3, 23, 63]: 73 | return "Philadelphia" 74 | elif self.region_code in [4, 24, 64]: 75 | return "Cleveland" 76 | elif self.region_code in [5, 25, 65]: 77 | return "Richmond" 78 | elif self.region_code in [6, 26, 66]: 79 | return "Atlanta" 80 | elif self.region_code in [7, 27, 67]: 81 | return "Chicago" 82 | elif self.region_code in [8, 28, 68]: 83 | return "St. Louis" 84 | elif self.region_code in [9, 29, 69]: 85 | return "Minneapolis" 86 | elif self.region_code in [10, 30, 70]: 87 | return "Kansas City" 88 | elif self.region_code in [11, 31, 71]: 89 | return "Dallas" 90 | elif self.region_code in [12, 32, 72]: 91 | return "San Francisco" 92 | elif self.region_code == 80: 93 | return "Traveller's Cheque" 94 | else: 95 | return None 96 | 97 | def to_s(self): 98 | return str(self.number) + " (valid: %s; type: %s; region: %s)" % \ 99 | (self.is_valid(), self.get_type(), self.get_region()) 100 | 101 | def __repr__(self): 102 | return self.to_s() 103 | 104 | -------------------------------------------------------------------------------- /fixofx/test/fixtures/nobankinfo_and_trnrs.ofc: -------------------------------------------------------------------------------- 1 | 2 | 2 3 | 1252 4 | 5 | 0 6 | 20110113152823 7 | 1246578033 8 | 0 9 | 0 10 | 0 11 | 12 | 13 | 0 14 | 4 15 | 16 | ********************************************** 17 | BRADESCO - Mensagens Informativas 18 | ********************************************** 19 | Obrigado por Utilizar o Nosso Sistema 20 | ______________________________________________ 21 | 22 | 23 | 24 | 1 25 | 0 26 | 27 | 20101214 28 | 20110113 29 | 350.66 30 | 31 | 32 | 0 33 | 20110110 34 | 24.98 35 | 10012011 3176230 00000 36 | 3176230 37 | 00112 TRANSF.AUT. C/C - Juliano Alberto Gimenes 38 | 39 | 40 | 41 | 42 | 0 43 | 20110110 44 | 5.00 45 | 10012011 8859143 00000 46 | 8859143 47 | 00351 DEP CC AUTOAT - Ag02188maq018859seq02143 48 | 49 | 50 | 51 | 52 | 1 53 | 20110110 54 | -13.91 55 | 10012011 0004555 00000 56 | 0004555 57 | 00901 VISA ELECTRON - CACULA 58 | 59 | 60 | 61 | 62 | 1 63 | 20110110 64 | -5.80 65 | 10012011 0163279 00000 66 | 0163279 67 | 00901 VISA ELECTRON - ALTERO RJ 68 | 69 | 70 | 71 | 72 | 1 73 | 20110110 74 | -22.80 75 | 10012011 0683403 00000 76 | 0683403 77 | 00901 VISA ELECTRON - CASA DO CABELEIREIRO 78 | 79 | 80 | 81 | 82 | 1 83 | 20110110 84 | -51.80 85 | 10012011 0781332 00000 86 | 0781332 87 | 00901 VISA ELECTRON - PALACIO DOS CRISTAIS 88 | 89 | 90 | 91 | 92 | 0 93 | 20110111 94 | 100.00 95 | 11012011 3826792 00000 96 | 3826792 97 | 00351 DEP CC AUTOAT - Ag00553maq023826seq04792 98 | 99 | 100 | 101 | 102 | 1 103 | 20110111 104 | -134.10 105 | 11012011 0381590 00000 106 | 0381590 107 | 00901 VISA ELECTRON - VIA MILANO 108 | 109 | 110 | 111 | 112 | 1 113 | 20110111 114 | -10.60 115 | 11012011 0750139 00000 116 | 0750139 117 | 00901 VISA ELECTRON - VIA NIA 118 | 119 | 120 | 121 | 122 | 1 123 | 20110111 124 | -103.00 125 | 11012011 0809630 00000 126 | 0809630 127 | 00901 VISA ELECTRON - SONHO DOS PES 128 | 129 | 130 | 131 | 132 | 0 133 | 20110112 134 | 63.00 135 | 12012011 0244443 00000 136 | 0244443 137 | 00412 TRANSF AUTORIZ - Masayuki Missao 138 | 139 | 140 | 141 | 142 | 1 143 | 20110112 144 | -19.90 145 | 12012011 0120055 00000 146 | 0120055 147 | 00901 VISA ELECTRON - ANTONELLA 148 | 149 | 150 | 151 | 152 | 1 153 | 20110112 154 | -159.00 155 | 12012011 0563632 00000 156 | 0563632 157 | 00901 VISA ELECTRON - ANDARELLA 158 | 159 | 160 | 161 | 162 | 1 163 | 20110112 164 | -29.90 165 | 12012011 0781387 00000 166 | 0781387 167 | 00901 VISA ELECTRON - PALACIO DOS CRISTAIS 168 | 169 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /fixofx/ofx/account.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | # Copyright 2005-2010 Wesabe, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # 17 | # ofx.account - container for information about a bank or credit card account. 18 | # 19 | class Account: 20 | def __init__(self, acct_type="", acct_number="", aba_number="", 21 | balance=None, desc=None, institution=None, ofx_block=None): 22 | self.balance = balance 23 | self.desc = desc 24 | self.institution = institution 25 | 26 | if ofx_block is not None: 27 | self.acct_type = self._get_from_ofx(ofx_block, "ACCTTYPE") 28 | self.acct_number = self._get_from_ofx(ofx_block, "ACCTID") 29 | self.aba_number = self._get_from_ofx(ofx_block, "BANKID") 30 | else: 31 | self.acct_type = acct_type 32 | self.acct_number = acct_number 33 | self.aba_number = aba_number 34 | 35 | def _get_from_ofx(self, data, key): 36 | data_dict = data.asDict() 37 | return data_dict.get(key, "") 38 | 39 | def get_ofx_accttype(self): 40 | # FIXME: I nominate this for the stupidest method in the Uploader. 41 | 42 | # OFX requests need to have a the account type match one of a few 43 | # known types. This converts from the "display" version of the 44 | # type to the one OFX servers will recognize. 45 | if self.acct_type == "Checking" or self.acct_type == "CHECKING": 46 | return "CHECKING" 47 | elif self.acct_type == "Savings" or self.acct_type == "SAVINGS": 48 | return "SAVINGS" 49 | elif self.acct_type == "Credit Card" or self.acct_type == "CREDITCARD": 50 | return "CREDITCARD" 51 | elif self.acct_type == "Money Market" or self.acct_type == "MONEYMRKT"\ 52 | or self.acct_type == "MONEYMARKT": 53 | return "MONEYMRKT" 54 | elif self.acct_type == "Credit Line" or self.acct_type == "CREDITLINE": 55 | return "CREDITLINE" 56 | else: 57 | return self.acct_type 58 | 59 | def is_complete(self): 60 | if self.institution is None: 61 | return False 62 | elif self.acct_type != "" and self.acct_number != "": 63 | if self.get_ofx_accttype() == "CREDITCARD": 64 | return True 65 | else: 66 | return self.aba_number != "" 67 | else: 68 | return False 69 | 70 | def is_equal(self, other): 71 | if self.acct_type == other.acct_type and \ 72 | self.acct_number == other.acct_number and \ 73 | self.aba_number == other.aba_number: 74 | return True 75 | else: 76 | return False 77 | 78 | def to_s(self): 79 | return ("Account: %s; Desc: %s; Type: %s; ABA: %s; Institution: %s") % \ 80 | (self.acct_number, self.desc, self.acct_type, 81 | self.aba_number, self.broker_id, self.institution) 82 | 83 | def __repr__(self): 84 | return self.to_s() 85 | 86 | def as_dict(self): 87 | acct_dict = { 'acct_number' : self.acct_number, 88 | 'acct_type' : self.get_ofx_accttype(), 89 | 'aba_number' : self.aba_number, 90 | 'balance' : self.balance, 91 | 'desc' : self.desc } 92 | if self.institution is not None: 93 | acct_dict['institution'] = self.institution.as_dict() 94 | return acct_dict 95 | 96 | def load_from_dict(acct_dict): 97 | return Account(acct_type=acct_dict.get('acct_type'), 98 | acct_number=acct_dict.get('acct_number'), 99 | aba_number=acct_dict.get('aba_number'), 100 | balance=acct_dict.get('balance'), 101 | desc=acct_dict.get('desc')) 102 | load_from_dict = staticmethod(load_from_dict) 103 | 104 | 105 | -------------------------------------------------------------------------------- /fixofx/test/test_ofx_client.py: -------------------------------------------------------------------------------- 1 | # Copyright 2005-2010 Wesabe, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import unittest 15 | from fixofx.ofx import Institution, Account, Client 16 | from fixofx.test.ofx_test_utils import get_creditcard_stmt, get_savings_stmt, get_checking_stmt 17 | from fixofx.test.test_mock_ofx_server import MockOfxServer 18 | 19 | 20 | class ClientTests(unittest.TestCase): 21 | def setUp(self): 22 | self.port = 9486 23 | self.server = MockOfxServer(port=self.port) 24 | self.mockurl = "http://localhost:" + str(self.port) + "/" 25 | self.institution = Institution(ofx_org="Test Bank", 26 | ofx_fid="99999", 27 | ofx_url=self.mockurl) 28 | self.checking_account = Account(acct_number="1122334455", 29 | aba_number="12345678", 30 | acct_type="Checking", 31 | institution=self.institution) 32 | self.savings_account = Account(acct_number="1122334455", 33 | aba_number="12345678", 34 | acct_type="Savings", 35 | institution=self.institution) 36 | self.creditcard_account = Account(acct_number="1122334455", 37 | aba_number="12345678", 38 | acct_type="Credit Card", 39 | institution=self.institution) 40 | self.username = "username" 41 | self.password = "password" 42 | self.client = Client() 43 | self.checking_stmt = get_checking_stmt().decode('utf-8') 44 | self.savings_stmt = get_savings_stmt().decode('utf-8') 45 | self.creditcard_stmt = get_creditcard_stmt().decode('utf-8') 46 | 47 | def test_checking_stmt_request(self): 48 | response = self.client.get_bank_statement(self.checking_account, 49 | self.username, 50 | self.password) 51 | self.assertEqual(response.as_string(), self.checking_stmt) 52 | 53 | def test_savings_stmt_request(self): 54 | response = self.client.get_bank_statement(self.savings_account, 55 | self.username, 56 | self.password) 57 | self.assertEqual(response.as_string(), self.savings_stmt) 58 | 59 | def test_creditcard_stmt_request(self): 60 | response = self.client.get_creditcard_statement(self.creditcard_account, 61 | self.username, 62 | self.password) 63 | self.assertEqual(response.as_string(), self.creditcard_stmt) 64 | 65 | def test_unknown_stmt_request(self): 66 | checking_response = self.client.get_statement(self.checking_account, 67 | self.username, 68 | self.password) 69 | self.assertEqual(checking_response.as_string(), self.checking_stmt) 70 | 71 | savings_response = self.client.get_statement(self.savings_account, 72 | self.username, 73 | self.password) 74 | self.assertEqual(savings_response.as_string(), self.savings_stmt) 75 | 76 | creditcard_response = self.client.get_statement(self.creditcard_account, 77 | self.username, 78 | self.password) 79 | self.assertEqual(creditcard_response.as_string(), self.creditcard_stmt) 80 | 81 | 82 | if __name__ == '__main__': 83 | unittest.main() 84 | -------------------------------------------------------------------------------- /fixofx/ofx/filetyper.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | # Copyright 2005-2010 Wesabe, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # 17 | # ofx.FileTyper - figures out the type of a data file. 18 | # 19 | 20 | import csv 21 | import re 22 | 23 | class FileTyper: 24 | def __init__(self, text): 25 | self.text = text 26 | 27 | def trust(self): 28 | if re.search("OFXHEADER:", self.text, re.IGNORECASE) != None: 29 | match = re.search("VERSION:(\d)(\d+)", self.text) 30 | if match == None: 31 | return "OFX/1" 32 | else: 33 | major = match.group(1) 34 | minor = match.group(2) 35 | return "OFX/%s.%s" % (major, minor) 36 | 37 | elif re.search('') != -1: 50 | return "OFC" 51 | 52 | elif re.search("^:20:", self.text, re.MULTILINE) != None and \ 53 | re.search("^\:60F\:", self.text, re.MULTILINE) != None and \ 54 | re.search("^-$", self.text, re.MULTILINE) != None: 55 | return "MT940" 56 | 57 | elif self.text.startswith('%PDF-'): 58 | return "PDF" 59 | 60 | elif self.text.find(' 0: 98 | frequencies[fields] = frequencies.get(fields, 0) + 1 99 | rows = rows + 1 100 | 101 | for fieldcount, frequency in list(frequencies.items()): 102 | percentage = (float(frequency) / float(rows)) * float(100) 103 | if fieldcount > 2 and percentage > 80: 104 | if dialect.delimiter == ",": 105 | return "CSV" 106 | elif dialect.delimiter == "\t": 107 | return "TSV" 108 | except Exception: 109 | pass 110 | 111 | # If we get all the way down here, we don't know what the file type is. 112 | return "UNKNOWN" 113 | 114 | -------------------------------------------------------------------------------- /fixofx/ofxtools/ofc_parser.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | 3 | # Copyright 2005-2010 Wesabe, Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # 18 | # ofxtools.ofc_parser - parser class for reading OFC documents. 19 | # 20 | import re 21 | 22 | from pyparsing import (alphanums, CharsNotIn, Dict, Forward, Group, 23 | Literal, OneOrMore, White, Word, ZeroOrMore) 24 | from pyparsing import ParseException 25 | 26 | from fixofx.ofxtools import _ofxtoolsStartDebugAction, _ofxtoolsSuccessDebugAction, _ofxtoolsExceptionDebugAction 27 | from fixofx.ofxtools.util import strip_empty_tags 28 | 29 | 30 | class OfcParser: 31 | """Dirt-simple OFC parser for interpreting OFC documents.""" 32 | def __init__(self, debug=False): 33 | aggregate = Forward().setResultsName("OFC") 34 | aggregate_open_tag, aggregate_close_tag = self._tag() 35 | content_open_tag = self._tag(closed=False) 36 | content = Group(content_open_tag + CharsNotIn("<\r\n")) 37 | aggregate << Group(aggregate_open_tag \ 38 | + Dict(OneOrMore(aggregate | content)) \ 39 | + aggregate_close_tag) 40 | 41 | self.parser = Group(aggregate).setResultsName("document") 42 | if (debug): 43 | self.parser.setDebugActions(_ofxtoolsStartDebugAction, 44 | _ofxtoolsSuccessDebugAction, 45 | _ofxtoolsExceptionDebugAction) 46 | 47 | def _tag(self, closed=True): 48 | """Generate parser definitions for OFX tags.""" 49 | openTag = Literal("<").suppress() + Word(alphanums + ".") \ 50 | + Literal(">").suppress() 51 | if (closed): 52 | closeTag = Group("" + ZeroOrMore(White())).suppress() 53 | return openTag, closeTag 54 | else: 55 | return openTag 56 | 57 | def parse(self, ofc): 58 | """Parse a string argument and return a tree structure representing 59 | the parsed document.""" 60 | ofc = self.add_zero_to_empty_ledger_tag(ofc) 61 | ofc = self.remove_inline_closing_tags(ofc) 62 | ofc = strip_empty_tags(ofc) 63 | ofc = self._translate_chknum_to_checknum(ofc) 64 | # if you don't have a good stomach, skip this part 65 | # XXX:needs better solution 66 | import sys 67 | sys.setrecursionlimit(5000) 68 | try: 69 | return self.parser.parseString(ofc).asDict() 70 | except ParseException: 71 | fixed_ofc = self.fix_ofc(ofc) 72 | return self.parser.parseString(fixed_ofc).asDict() 73 | 74 | def add_zero_to_empty_ledger_tag(self, ofc): 75 | """ 76 | Fix an OFC, by adding zero to LEDGER blank tag 77 | """ 78 | return re.compile(r'(\D*\n)', re.UNICODE).sub(r'0\1', ofc) 79 | 80 | def remove_inline_closing_tags(self, ofc): 81 | """ 82 | Fix an OFC, by removing inline closing 'tags' 83 | """ 84 | return re.compile(r'(\w+.*)<\/\w+>', re.UNICODE).sub(r'\1', ofc) 85 | 86 | def fix_ofc(self, ofc): 87 | """ 88 | Do some magic to fix an bad OFC 89 | """ 90 | ofc = self._remove_bad_tags(ofc) 91 | ofc = self._fill_dummy_tags(ofc) 92 | return self._inject_tags(ofc) 93 | 94 | def _remove_bad_tags(self, ofc): 95 | ofc_without_trnrs = re.sub(r'<[/]*TRNRS>', '', ofc) 96 | return re.sub(r'<[/]*CLTID>\w+', '', ofc_without_trnrs) 97 | 98 | def _fill_dummy_tags(self, ofc): 99 | expression = r'(<%s>)[^\w+]' 100 | replacement = r'<%s>0\n' 101 | ofc = re.sub(expression % 'FITID', replacement % 'FITID' , ofc) 102 | filled_ofc = re.sub(expression % 'CHECKNUM', replacement % 'CHECKNUM' , ofc) 103 | 104 | return filled_ofc 105 | 106 | def _translate_chknum_to_checknum(self, ofc): 107 | """ 108 | Some banks put an CHKNUM instead of CHECKNUM. this method translates 109 | CHKNUM to CHECKNUM in order to parse this information correctly 110 | """ 111 | return re.sub('CHKNUM', 'CHECKNUM', ofc) 112 | 113 | def _inject_tags(self, ofc): 114 | tags ="\n\n\n0\n0\n0\n\n" 115 | if not re.findall(r'\w*\s*', ofc): 116 | return ofc.replace('', tags).replace('', '\n') 117 | -------------------------------------------------------------------------------- /fixofx/ofx/error.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | # Copyright 2005-2010 Wesabe, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # 17 | # ofx.error - OFX error message exception 18 | # 19 | 20 | 21 | class Error(Exception): 22 | def __init__(self, summary, code=None, severity=None, message=None): 23 | self.summary = summary 24 | self.code = int(code) 25 | self.severity = severity 26 | self.msg = message 27 | 28 | self.codetable = \ 29 | { 0: "OK", 30 | 1: "Client is up-to-date", 31 | 2000: "General error", 32 | 2001: "Invalid account", 33 | 2002: "General account error", 34 | 2003: "Account not found", 35 | 2004: "Account closed", 36 | 2005: "Account not authorized", 37 | 2006: "Source account not found", 38 | 2007: "Source account closed", 39 | 2008: "Source account not authorized", 40 | 2009: "Destination account not found", 41 | 2010: "Destination account closed", 42 | 2011: "Destination account not authorized", 43 | 2012: "Invalid amount", 44 | # Don't know why 2013 is missing from spec (1.02) 45 | 2014: "Date too soon", 46 | 2015: "Date too far in the future", 47 | 2016: "Already committed", 48 | 2017: "Already cancelled", 49 | 2018: "Unknown server ID", 50 | 2019: "Duplicate request", 51 | 2020: "Invalid date", 52 | 2021: "Unsupported version", 53 | 2022: "Invalid TAN", 54 | 10000: "Stop check in process", 55 | 10500: "Too many checks to process", 56 | 10501: "Invalid payee", 57 | 10502: "Invalid payee address", 58 | 10503: "Invalid payee account number", 59 | 10504: "Insufficient funds", 60 | 10505: "Cannot modify element", 61 | 10506: "Cannot modify source account", 62 | 10507: "Cannot modify destination account", 63 | 10508: "Invalid frequency", # "..., Kenneth" 64 | 10509: "Model already cancelled", 65 | 10510: "Invalid payee ID", 66 | 10511: "Invalid payee city", 67 | 10512: "Invalid payee state", 68 | 10513: "Invalid payee postal code", 69 | 10514: "Bank payment already processed", 70 | 10515: "Payee not modifiable by client", 71 | 10516: "Wire beneficiary invalid", 72 | 10517: "Invalid payee name", 73 | 10518: "Unknown model ID", 74 | 10519: "Invalid payee list ID", 75 | 12250: "Investment transaction download not supported", 76 | 12251: "Investment position download not supported", 77 | 12252: "Investment positions for specified date not available", 78 | 12253: "Investment open order download not supoorted", 79 | 12254: "Investment balances download not supported", 80 | 12500: "One or more securities not found", 81 | 13000: "User ID & password will be sent out-of-band", 82 | 13500: "Unable to enroll user", 83 | 13501: "User already enrolled", 84 | 13502: "Invalid service", 85 | 13503: "Cannot change user information", 86 | 15000: "Must change USERPASS", 87 | 15500: "Signon (for example, user ID or password) invalid", 88 | 15501: "Customer account already in use", 89 | 15502: "USERPASS lockout", 90 | 15503: "Could not change USERPASS", 91 | 15504: "Could not provide random data", 92 | 16500: "HTML not allowed", 93 | 16501: "Unknown mail To:", 94 | 16502: "Invalid URL", 95 | 16503: "Unable to get URL", } 96 | 97 | def interpret_code(self, code=None): 98 | if code is None: 99 | code = self.code 100 | 101 | if code in self.codetable: 102 | return self.codetable[code] 103 | else: 104 | return "Unknown error code" 105 | 106 | def str(self): 107 | format = "%s\n(%s %s: %s)" 108 | return format % (self.msg, self.severity, self.code, 109 | self.interpret_code()) 110 | 111 | def __str__(self): 112 | return self.str() 113 | 114 | def __repr__(self): 115 | return self.str() 116 | -------------------------------------------------------------------------------- /fixofx/test/test_ofx_request.py: -------------------------------------------------------------------------------- 1 | # Copyright 2005-2010 Wesabe, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import unittest 15 | 16 | from fixofx.ofx import Account, Request, Parser, Institution 17 | 18 | 19 | class RequestTests(unittest.TestCase): 20 | def setUp(self): 21 | self.request = Request() 22 | self.institution = Institution(ofx_org="fi_name", ofx_fid="1000") 23 | self.account = Account(acct_number="00112233", 24 | aba_number="12345678", 25 | acct_type="Checking", 26 | institution=self.institution) 27 | self.username = "joeuser" 28 | self.password = "mypasswd" 29 | self.parser = Parser() 30 | 31 | # FIXME: Need to add tests for date formatting. 32 | 33 | def test_header(self): 34 | """Test the correctness of an OFX document header by examining 35 | some of the dynamically-generated values at the bottom of the 36 | header. This test uses a bank statement request, since that 37 | is our most common use, and since that will build a full, parsable 38 | document, including the header.""" 39 | parsetree = self.parser.parse(self.request.bank_stmt(self.account, 40 | self.username, 41 | self.password)) 42 | self.assertEqual("NONE", parsetree["header"]["OLDFILEUID"]) 43 | self.assertNotEqual("NONE", parsetree["header"]["NEWFILEUID"]) 44 | 45 | def test_sign_on(self): 46 | """Test the OFX document sign-on block, using a bank statement 47 | request again.""" 48 | parsetree = self.parser.parse(self.request.bank_stmt(self.account, 49 | self.username, 50 | self.password)) 51 | # FIXME: add DTCLIENT test here. 52 | signon = parsetree["body"]["OFX"]["SIGNONMSGSRQV1"]["SONRQ"] 53 | self.assertEqual("joeuser", signon["USERID"]) 54 | self.assertEqual("mypasswd", signon["USERPASS"]) 55 | self.assertEqual("fi_name", signon["FI"]["ORG"]) 56 | self.assertEqual("1000", signon["FI"]["FID"]) 57 | self.assertEqual("Money", signon["APPID"]) 58 | self.assertEqual("1400", signon["APPVER"]) 59 | 60 | def test_account_info(self): 61 | """Test the values sent for an account info request.""" 62 | parsetree = self.parser.parse(self.request.account_info(self.institution, 63 | self.username, 64 | self.password)) 65 | info = parsetree["body"]["OFX"]["SIGNUPMSGSRQV1"]["ACCTINFOTRNRQ"] 66 | self.assertNotEqual("NONE", info["TRNUID"]) 67 | self.assertEqual("4", info["CLTCOOKIE"]) 68 | self.assertEqual("19980101", info["ACCTINFORQ"]["DTACCTUP"]) 69 | 70 | def test_bank_stmt(self): 71 | """Test the specific values for a bank statement request.""" 72 | parsetree = self.parser.parse(self.request.bank_stmt(self.account, 73 | self.username, 74 | self.password)) 75 | stmt = parsetree["body"]["OFX"]["BANKMSGSRQV1"]["STMTTRNRQ"] 76 | self.assertNotEqual("NONE", stmt["TRNUID"]) 77 | self.assertEqual("4", stmt["CLTCOOKIE"]) 78 | self.assertEqual("12345678", stmt["STMTRQ"]["BANKACCTFROM"]["BANKID"]) 79 | self.assertEqual("00112233", stmt["STMTRQ"]["BANKACCTFROM"]["ACCTID"]) 80 | self.assertEqual("CHECKING",stmt["STMTRQ"]["BANKACCTFROM"]["ACCTTYPE"]) 81 | # FIXME: Add DTSTART and DTEND tests here. 82 | 83 | def test_creditcard_stmt(self): 84 | """Test the specific values for a credit card statement request.""" 85 | self.account.acct_number = "412345678901" 86 | parsetree = self.parser.parse(self.request.creditcard_stmt(self.account, 87 | self.username, 88 | self.password)) 89 | stmt = parsetree["body"]["OFX"]["CREDITCARDMSGSRQV1"]["CCSTMTTRNRQ"] 90 | self.assertNotEqual("NONE", stmt["TRNUID"]) 91 | self.assertEqual("4", stmt["CLTCOOKIE"]) 92 | self.assertEqual("412345678901", stmt["CCSTMTRQ"]["CCACCTFROM"]["ACCTID"]) 93 | # FIXME: Add DTSTART and DTEND tests here. 94 | 95 | if __name__ == '__main__': 96 | unittest.main() 97 | -------------------------------------------------------------------------------- /fixofx/test/fixtures/invalid_blank_tag_ledger.ofc: -------------------------------------------------------------------------------- 1 | 2 | 2 3 | 1252 4 | 5 | 6 | 041 7 | 06433500275606 8 | 0 9 | 10 | 11 | 20111101 12 | 20111130 13 | 14 | 15 | 0 16 | 20111101 17 | 800.00 18 | 149109 19 | 149109 20 | 6051 21 | CR.TRANSFERENCIA 22 | CR.TRANSFERENCIA 23 | 24 | 25 | 1 26 | 20111101 27 | -130.00 28 | 011716 29 | 011716 30 | 6051 31 | SQ.CASH INT IA 32 | SQ.CASH INT IA 33 | 34 | 35 | 1 36 | 20111101 37 | -847.15 38 | 131523 39 | 131523 40 | 6051 41 | PG.TITULO 42 | PG.TITULO 43 | 44 | 45 | 1 46 | 20111101 47 | -146.66 48 | 131437 49 | 131437 50 | 6051 51 | PG. CARTAO CREDI 52 | PG. CARTAO CREDI 53 | 54 | 55 | 1 56 | 20111101 57 | -171.33 58 | 131436 59 | 131436 60 | 6051 61 | DB.TRANSFERENCIA 62 | DB.TRANSFERENCIA 63 | 64 | 65 | 1 66 | 20111101 67 | -0.60 68 | 000000 69 | 000000 70 | 6051 71 | IOF 72 | IOF 73 | 74 | 75 | 1 76 | 20111101 77 | -1.16 78 | 000000 79 | 000000 80 | 6051 81 | IOF ADICIONAL 82 | IOF ADICIONAL 83 | 84 | 85 | 5 86 | 20111103 87 | 2115.12 88 | 029355 89 | 029355 90 | 6051 91 | DEP.CHEQUE - IA 92 | DEP.CHEQUE - IA 93 | 94 | 95 | 0 96 | 20111103 97 | 150.00 98 | 149350 99 | 149350 100 | 6051 101 | CR.TRANSFERENCIA 102 | CR.TRANSFERENCIA 103 | 104 | 105 | 1 106 | 20111103 107 | -10.00 108 | 006478 109 | 006478 110 | 6051 111 | SAQ.CASH EXTERNO 112 | SAQ.CASH EXTERNO 113 | 114 | 115 | 1 116 | 20111103 117 | -140.00 118 | 008499 119 | 008499 120 | 6051 121 | SQ.CASH INT IA 122 | SQ.CASH INT IA 123 | 124 | 125 | 1 126 | 20111103 127 | -5.00 128 | 031111 129 | 031111 130 | 6051 131 | COMPRAS A VISTA 132 | COMPRAS A VISTA 133 | 134 | 135 | 1 136 | 20111103 137 | -33.30 138 | 030911 139 | 030911 140 | 6051 141 | COMPRAS PRE/PARC 142 | COMPRAS PRE/PARC 143 | 144 | 145 | 1 146 | 20111107 147 | -1675.00 148 | 054520 149 | 054520 150 | 6051 151 | SAQ. ELETRON-IA 152 | SAQ. ELETRON-IA 153 | 154 | 155 | 1 156 | 20111107 157 | -7.50 158 | 061111 159 | 061111 160 | 6051 161 | COMPRAS A VISTA 162 | COMPRAS A VISTA 163 | 164 | 165 | 1 166 | 20111107 167 | -5.50 168 | 071111 169 | 071111 170 | 6051 171 | COMPRAS A VISTA 172 | COMPRAS A VISTA 173 | 174 | 175 | 1 176 | 20111108 177 | -3.50 178 | 081111 179 | 081111 180 | 6051 181 | COMPRAS A VISTA 182 | COMPRAS A VISTA 183 | 184 | 185 | 1 186 | 20111108 187 | -13.61 188 | 081111 189 | 081111 190 | 6051 191 | COMPRAS A VISTA 192 | COMPRAS A VISTA 193 | 194 | 195 | 1 196 | 20111110 197 | -58.90 198 | 001827 199 | 001827 200 | 6051 201 | PG.TELEFONIA/NET 202 | PG.TELEFONIA/NET 203 | 204 | 205 | 1 206 | 20111110 207 | -318.00 208 | 001733 209 | 001733 210 | 6051 211 | PG.TITULO 212 | PG.TITULO 213 | 214 | 215 | 1 216 | 20111111 217 | -24.90 218 | 121011 219 | 121011 220 | 6051 221 | COMPRAS PRE/PARC 222 | COMPRAS PRE/PARC 223 | 224 | 225 | 1 226 | 20111114 227 | -30.00 228 | 151011 229 | 151011 230 | 6051 231 | COMPRAS PRE/PARC 232 | COMPRAS PRE/PARC 233 | 234 | 235 | 1 236 | 20111130 237 | -35.00 238 | 011011 239 | 011011 240 | 6051 241 | COMPRAS PRE/PARC 242 | COMPRAS PRE/PARC 243 | 244 | 245 | 1 246 | 20111202 247 | -33.30 248 | 030911 249 | 030911 250 | 6051 251 | COMPRAS PRE/PARC 252 | COMPRAS PRE/PARC 253 | 254 | 255 | 256 | 257 | -------------------------------------------------------------------------------- /fixofx/test/test_ofx_builder.py: -------------------------------------------------------------------------------- 1 | # Copyright 2005-2010 Wesabe, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import unittest 15 | 16 | from fixofx.ofx.builder import * 17 | 18 | from fixofx.ofx.builder import Tag # not exported by default 19 | 20 | 21 | class BuilderTests(unittest.TestCase): 22 | def test_blank_node(self): 23 | """Test generation of a blank node tag.""" 24 | BLANK = Tag("BLANK") 25 | self.assertEqual("\r\n", BLANK()) 26 | 27 | def test_node(self): 28 | """Test generation of a node tag.""" 29 | NODE = Tag("NODE") 30 | self.assertEqual("text\r\n", NODE("text")) 31 | 32 | def test_blank_aggregate_node(self): 33 | """Test generation of an empty aggregate tag.""" 34 | AGGREGATE = Tag("AGGREGATE", aggregate=True) 35 | self.assertEqual("\r\n\r\n", AGGREGATE()) 36 | 37 | def test_nested_tags(self): 38 | """Test generation of an aggregate containing three nodes.""" 39 | ONE = Tag("ONE") 40 | TWO = Tag("TWO") 41 | THREE = Tag("THREE") 42 | CONTAINER = Tag("CONTAINER", aggregate=True) 43 | self.assertEqual( 44 | "\r\none\r\ntwo\r\nthree\r\n\r\n", 45 | CONTAINER(ONE("one"), TWO("two"), THREE("three"))) 46 | 47 | def test_blank_header(self): 48 | """Test generation of a blank header.""" 49 | HEADER = Tag("HEADER", header=True) 50 | self.assertEqual("HEADER:\r\n", HEADER()) 51 | 52 | def test_header(self): 53 | """Test generation of a header.""" 54 | ONE = Tag("ONE", header=True) 55 | self.assertEqual("ONE:value\r\n", ONE("value")) 56 | 57 | def test_blank_header_block(self): 58 | """Stupid test of a blank header block.""" 59 | BLOCK = Tag("", header_block=True) 60 | self.assertEqual("\r\n", BLOCK()) 61 | 62 | def test_header_block(self): 63 | ONE = Tag("ONE", header=True) 64 | TWO = Tag("TWO", header=True) 65 | THREE = Tag("THREE", header=True) 66 | BLOCK = Tag("", header_block=True) 67 | self.assertEqual("ONE:one\r\nTWO:two\r\nTHREE:three\r\n\r\n", 68 | BLOCK(ONE("one"), TWO("two"), THREE("three"))) 69 | 70 | def test_bankaccount_request(self): 71 | """Generate a full, real OFX message, and compare it to static 72 | test data.""" 73 | testquery = DOCUMENT( 74 | HEADER( 75 | OFXHEADER("100"), 76 | DATA("OFXSGML"), 77 | VERSION("102"), 78 | SECURITY("NONE"), 79 | ENCODING("USASCII"), 80 | CHARSET("1252"), 81 | COMPRESSION("NONE"), 82 | OLDFILEUID("NONE"), 83 | NEWFILEUID("9B33CA3E-C237-4577-8F00-7AFB0B827B5E")), 84 | OFX( 85 | SIGNONMSGSRQV1( 86 | SONRQ( 87 | DTCLIENT("20060221150810"), 88 | USERID("username"), 89 | USERPASS("userpass"), 90 | LANGUAGE("ENG"), 91 | FI( 92 | ORG("FAKEOFX"), 93 | FID("1000")), 94 | APPID("MONEY"), 95 | APPVER("1200"))), 96 | BANKMSGSRQV1( 97 | STMTTRNRQ( 98 | TRNUID("9B33CA3E-C237-4577-8F00-7AFB0B827B5E"), 99 | CLTCOOKIE("4"), 100 | STMTRQ( 101 | BANKACCTFROM( 102 | BANKID("2000"), 103 | ACCTID("12345678"), 104 | ACCTTYPE("CHECKING")), 105 | INCTRAN( 106 | DTSTART("20060221150810"), 107 | INCLUDE("Y"))))))) 108 | 109 | controlquery = "OFXHEADER:100\r\nDATA:OFXSGML\r\nVERSION:102\r\nSECURITY:NONE\r\nENCODING:USASCII\r\nCHARSET:1252\r\nCOMPRESSION:NONE\r\nOLDFILEUID:NONE\r\nNEWFILEUID:9B33CA3E-C237-4577-8F00-7AFB0B827B5E\r\n\r\n\r\n\r\n\r\n20060221150810\r\nusername\r\nuserpass\r\nENG\r\n\r\nFAKEOFX\r\n1000\r\n\r\nMONEY\r\n1200\r\n\r\n\r\n\r\n\r\n9B33CA3E-C237-4577-8F00-7AFB0B827B5E\r\n4\r\n\r\n\r\n2000\r\n12345678\r\nCHECKING\r\n\r\n\r\n20060221150810\r\nY\r\n\r\n\r\n\r\n\r\n" 110 | self.assertEqual(testquery, controlquery) 111 | 112 | if __name__ == '__main__': 113 | unittest.main() 114 | -------------------------------------------------------------------------------- /fixofx/test/fixtures/bad.ofc: -------------------------------------------------------------------------------- 1 | 2 | 3 | 0310166442 4 | 5 | 20080710 6 | 20080811 7 | 1061.03 8 | 9 | 10 | 1 11 | 20080710 12 | 742.50 13 | 14 | 15 | DOC 409.0190INFNET EDUCA 16 | 17 | 18 | 19 | 20 | 1 21 | 20080711 22 | -500.00 23 | 24 | 25 | CEI SAQUE 000042.001073 26 | 27 | 28 | 29 | 30 | 1 31 | 20080711 32 | -150.00 33 | 34 | 35 | CEI SAQUE 000059.001073 36 | 37 | 38 | 39 | 40 | 1 41 | 20080711 42 | 1900.97 43 | 44 | 45 | TBI 6012.01284-2EBBS 46 | 47 | 48 | 49 | 50 | 1 51 | 20080711 52 | -19.80 53 | 54 | 55 | TAR MAXCTA PJ MENS 06/08 56 | 57 | 58 | 59 | 60 | 1 61 | 20080714 62 | -17.16 63 | 64 | 65 | RSHOP-MINI MAX TI-001073 66 | 67 | 68 | 69 | 70 | 1 71 | 20080714 72 | -40.00 73 | 74 | 75 | TBI 0301.75492-5EBBS 76 | 77 | 78 | 79 | 80 | 1 81 | 20080714 82 | -191.00 83 | 84 | 85 | TBI 0726.41321-4Eduardo 86 | 87 | 88 | 89 | 90 | 1 91 | 20080714 92 | -1720.22 93 | 94 | 95 | DOC BKI 089570 INFORMAL 96 | 97 | 98 | 99 | 100 | 1 101 | 20080714 102 | -7.80 103 | 104 | 105 | TAR DOC BKI 106 | 107 | 108 | 109 | 110 | 1 111 | 20080715 112 | -2.99 113 | 114 | 115 | RSHOP-DROG DESCON-001073 116 | 117 | 118 | 119 | 120 | 1 121 | 20080717 122 | 105.54 123 | 124 | 125 | TEC DEP CHEQUE 126 | 127 | 128 | 129 | 130 | 1 131 | 20080722 132 | -70.00 133 | 134 | 135 | CEI SAQUE 000349.001073 136 | 137 | 138 | 139 | 140 | 1 141 | 20080724 142 | 135.48 143 | 144 | 145 | TBI 0769.31862-7 C/C 146 | 147 | 148 | 149 | 150 | 1 151 | 20080728 152 | -9.58 153 | 154 | 155 | RSHOP-MINI MAX TI-001073 156 | 157 | 158 | 159 | 160 | 1 161 | 20080728 162 | -14.68 163 | 164 | 165 | RSHOP-MINI MAX TI-001073 166 | 167 | 168 | 169 | 170 | 1 171 | 20080728 172 | -15.95 173 | 174 | 175 | RSHOP-MOLL ROSARI-001073 176 | 177 | 178 | 179 | 180 | 1 181 | 20080729 182 | -80.00 183 | 184 | 185 | CEI SAQUE 000661.001073 186 | 187 | 188 | 189 | 190 | 1 191 | 20080729 192 | 450.00 193 | 194 | 195 | TEC DEPOSITO DINHEIRO 196 | 197 | 198 | 199 | 200 | 1 201 | 20080730 202 | -50.00 203 | 204 | 205 | CEI SAQUE 000182.001073 206 | 207 | 208 | 209 | 210 | 1 211 | 20080730 212 | -10.00 213 | 214 | 215 | RSHOP-RESTAURANTE-001073 216 | 217 | 218 | 219 | 220 | 1 221 | 20080731 222 | -25.00 223 | 224 | 225 | RSHOP-RIO NORTE -001073 226 | 227 | 228 | 229 | 230 | 1 231 | 20080731 232 | -308.85 233 | 234 | 235 | BKI TELEMAR OI 125168480 236 | 237 | 238 | 239 | 240 | 1 241 | 20080801 242 | -40.00 243 | 244 | 245 | CEI SAQUE 001925.001073 246 | 247 | 248 | 249 | 250 | 1 251 | 20080804 252 | -19.80 253 | 254 | 255 | TAR MAXCTA PJ MENS 07/08 256 | 257 | 258 | 259 | 260 | 1 261 | 20080805 262 | -30.00 263 | 264 | 265 | RSHOP-NOVA PARTNE-001073 266 | 267 | 268 | 269 | 270 | 1 271 | 20080811 272 | -19.36 273 | 274 | 275 | RSHOP-MINI MAX TI-001073 276 | 277 | 278 | 279 | 280 | 1 281 | 20080811 282 | 1059.48 283 | 284 | 285 | TBI 6012.01284-2EBBS 286 | 287 | 288 | 289 | 290 | 291 | -------------------------------------------------------------------------------- /fixofx/test/test_ofx_validators.py: -------------------------------------------------------------------------------- 1 | # Copyright 2005-2010 Wesabe, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import unittest 15 | 16 | from fixofx.ofx import RoutingNumber 17 | 18 | 19 | class ValidatorTests(unittest.TestCase): 20 | def setUp(self): 21 | self.good_aba = RoutingNumber("314074269") 22 | self.bad_aba = RoutingNumber("123456789") 23 | 24 | def test_not_a_number(self): 25 | nan = RoutingNumber("123abd") 26 | self.assertEqual(nan.is_valid(), False) 27 | self.assertEqual(nan.get_type(), None) 28 | self.assertEqual(nan.get_region(), None) 29 | self.assertEqual(str(nan), 30 | "123abd (valid: False; type: None; region: None)") 31 | 32 | def test_valid_aba(self): 33 | self.assertEqual(self.good_aba.is_valid(), True) 34 | self.assertEqual(self.bad_aba.is_valid(), False) 35 | 36 | def test_aba_types(self): 37 | self.assertEqual(RoutingNumber("001234567").get_type(), 38 | "United States Government") 39 | self.assertEqual(RoutingNumber("011234567").get_type(), 40 | "Primary") 41 | self.assertEqual(RoutingNumber("071234567").get_type(), 42 | "Primary") 43 | self.assertEqual(RoutingNumber("121234567").get_type(), 44 | "Primary") 45 | self.assertEqual(RoutingNumber("131234567").get_type(), 46 | None) 47 | self.assertEqual(RoutingNumber("201234567").get_type(), 48 | None) 49 | self.assertEqual(RoutingNumber("211234567").get_type(), 50 | "Thrift") 51 | self.assertEqual(RoutingNumber("251234567").get_type(), 52 | "Thrift") 53 | self.assertEqual(RoutingNumber("321234567").get_type(), 54 | "Thrift") 55 | self.assertEqual(RoutingNumber("331234567").get_type(), 56 | None) 57 | self.assertEqual(RoutingNumber("601234567").get_type(), 58 | None) 59 | self.assertEqual(RoutingNumber("611234567").get_type(), 60 | "Electronic") 61 | self.assertEqual(RoutingNumber("641234567").get_type(), 62 | "Electronic") 63 | self.assertEqual(RoutingNumber("721234567").get_type(), 64 | "Electronic") 65 | self.assertEqual(RoutingNumber("731234567").get_type(), 66 | None) 67 | self.assertEqual(RoutingNumber("791234567").get_type(), 68 | None) 69 | self.assertEqual(RoutingNumber("801234567").get_type(), 70 | "Traveller's Cheque") 71 | self.assertEqual(RoutingNumber("811234567").get_type(), 72 | None) 73 | 74 | def test_aba_regions(self): 75 | self.assertEqual(RoutingNumber("001234567").get_region(), 76 | "United States Government") 77 | self.assertEqual(RoutingNumber("011234567").get_region(), 78 | "Boston") 79 | self.assertEqual(RoutingNumber("071234567").get_region(), 80 | "Chicago") 81 | self.assertEqual(RoutingNumber("121234567").get_region(), 82 | "San Francisco") 83 | self.assertEqual(RoutingNumber("131234567").get_region(), 84 | None) 85 | self.assertEqual(RoutingNumber("201234567").get_region(), 86 | None) 87 | self.assertEqual(RoutingNumber("211234567").get_region(), 88 | "Boston") 89 | self.assertEqual(RoutingNumber("251234567").get_region(), 90 | "Richmond") 91 | self.assertEqual(RoutingNumber("321234567").get_region(), 92 | "San Francisco") 93 | self.assertEqual(RoutingNumber("331234567").get_region(), 94 | None) 95 | self.assertEqual(RoutingNumber("601234567").get_region(), 96 | None) 97 | self.assertEqual(RoutingNumber("611234567").get_region(), 98 | "Boston") 99 | self.assertEqual(RoutingNumber("641234567").get_region(), 100 | "Cleveland") 101 | self.assertEqual(RoutingNumber("721234567").get_region(), 102 | "San Francisco") 103 | self.assertEqual(RoutingNumber("731234567").get_region(), 104 | None) 105 | self.assertEqual(RoutingNumber("791234567").get_region(), 106 | None) 107 | self.assertEqual(RoutingNumber("801234567").get_region(), 108 | "Traveller's Cheque") 109 | self.assertEqual(RoutingNumber("811234567").get_region(), 110 | None) 111 | 112 | def test_aba_string(self): 113 | self.assertEqual(str(self.good_aba), 114 | "314074269 (valid: True; type: Thrift; region: Dallas)") 115 | 116 | 117 | if __name__ == '__main__': 118 | unittest.main() 119 | -------------------------------------------------------------------------------- /fixofx/ofx/parser.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | 3 | # Copyright 2005-2010 Wesabe, Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # 18 | # ofx.parser - parser class for reading OFX documents. 19 | # 20 | 21 | import re 22 | import sys 23 | 24 | from pyparsing import (alphanums, alphas, CharsNotIn, Dict, Forward, Group, 25 | Literal, OneOrMore, Optional, White, Word, ZeroOrMore) 26 | 27 | from fixofx.ofxtools.util import strip_empty_tags 28 | 29 | 30 | def _ofxStartDebugAction( instring, loc, expr ): 31 | sys.stderr.write("Match %s at loc %s (%d,%d)" % 32 | (expr, loc, 33 | instring.count("\n", 0, loc) + 1, 34 | loc - instring.rfind("\n", 0, loc))) 35 | 36 | def _ofxSuccessDebugAction( instring, startloc, endloc, expr, toks ): 37 | sys.stderr.write("Matched %s -> %s" % (expr, str(toks.asList()))) 38 | 39 | def _ofxExceptionDebugAction( instring, loc, expr, exc ): 40 | sys.stderr.write("Exception raised: %s" % exc) 41 | 42 | class Parser: 43 | """Dirt-simple OFX parser for interpreting server results (primarily for 44 | errors at this point). Currently parses OFX 1.02.""" 45 | def __init__(self, debug=False): 46 | # Parser definition for headers 47 | header = Group(Word(alphas) + Literal(":").suppress() + 48 | Optional(CharsNotIn("\r\n"))) 49 | headers = Dict(OneOrMore(header)).setResultsName("header") 50 | 51 | # Parser definition for OFX body 52 | aggregate = Forward().setResultsName("OFX") 53 | aggregate_open_tag, aggregate_close_tag = self._tag() 54 | content_open_tag = self._tag(closed=False) 55 | content = Group(content_open_tag + CharsNotIn("<\r\n")) 56 | aggregate << Group(aggregate_open_tag \ 57 | + Dict(ZeroOrMore(aggregate | content)) \ 58 | + aggregate_close_tag) 59 | body = Group(aggregate).setResultsName("body") 60 | 61 | # The parser as a whole 62 | self.parser = headers + body 63 | if (debug): 64 | self.parser.setDebugActions(_ofxStartDebugAction, _ofxSuccessDebugAction, _ofxExceptionDebugAction) 65 | 66 | def _tag(self, closed=True): 67 | """Generate parser definitions for OFX tags.""" 68 | openTag = Literal("<").suppress() + Word(alphanums + ".") \ 69 | + Literal(">").suppress() 70 | if (closed): 71 | closeTag = Group("" + ZeroOrMore(White())).suppress() 72 | return openTag, closeTag 73 | else: 74 | return openTag 75 | 76 | def parse(self, ofx): 77 | """Parse a string argument and return a tree structure representing 78 | the parsed document.""" 79 | if(isinstance(ofx, bytes)): 80 | ofx = ofx.decode('utf-8') 81 | 82 | ofx = strip_empty_tags(ofx) 83 | ofx = self.strip_close_tags(ofx) 84 | ofx = self.strip_blank_dtasof(ofx) 85 | ofx = self.strip_junk_ascii(ofx) 86 | ofx = self.fix_unknown_account_type(ofx) 87 | 88 | parsed = self.parser.parseString(ofx).asDict() 89 | 90 | def add_on_presence(k): 91 | if k in parsed["body"]["OFX"][0]: 92 | parsed["body"]["OFX"][k] = parsed["body"]["OFX"][0][k] 93 | 94 | add_on_presence("SIGNONMSGSRSV1") 95 | add_on_presence("SIGNONMSGSRQV1") 96 | add_on_presence("CREDITCARDMSGSRSV1") 97 | add_on_presence("BANKMSGSRSV1") 98 | add_on_presence("CREDITCARDMSGSRQV1") 99 | add_on_presence("BANKMSGSRQV1") 100 | add_on_presence("SIGNUPMSGSRQV1") 101 | 102 | return parsed 103 | 104 | def strip_close_tags(self, ofx): 105 | """Strips close tags on non-aggregate nodes. Close tags seem to be 106 | valid OFX/1.x, but they screw up our parser definition and are optional. 107 | This allows me to keep using the same parser without having to re-write 108 | it from scratch just yet.""" 109 | strip_search = '<(?P[^>]+)>\s*(?P[^<\n\r]+)(?:\s*)?(?P[\n\r]*)' 110 | return re.sub(strip_search, '<\g>\g\g', ofx) 111 | 112 | def strip_blank_dtasof(self, ofx): 113 | """Strips empty dtasof tags from wells fargo/wachovia downloads. Again, it would 114 | be better to just rewrite the parser, but for now this is a workaround.""" 115 | blank_search = '<(DTASOF|BALAMT|BANKID|CATEGORY|NAME|MEMO)>[\n\r]+' 116 | return re.sub(blank_search, '', ofx) 117 | 118 | def strip_junk_ascii(self, ofx): 119 | """Strips high ascii gibberish characters from Schwab statements. They seem to 120 | contains strings of EF BF BD EF BF BD 0A 08 EF BF BD 64 EF BF BD in the field, 121 | and the newline is screwing up the parser.""" 122 | return re.sub('[\xBD-\xFF\x64\x0A\x08]{4,}', '', ofx) 123 | 124 | def fix_unknown_account_type(self, ofx): 125 | """Sets the content of nodes without content to be UNKNOWN so that the 126 | parser is able to parse it. This isn't really the best solution, but it's a decent workaround.""" 127 | return re.sub('(?P[<\n\r])', 'UNKNOWN\g', ofx) 128 | 129 | -------------------------------------------------------------------------------- /fixofx/ofx/request.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | # Copyright 2005-2010 Wesabe, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # 17 | # ofx.request - build an OFX request document 18 | # 19 | 20 | import datetime 21 | import uuid 22 | 23 | from fixofx.ofx.builder import * 24 | 25 | 26 | class Request: 27 | def __init__(self, cookie=4, app_name="Money", app_version="1400"): 28 | # Note that American Express, at least, requires the app name 29 | # to be titlecase, and not all uppercase, for the request to 30 | # succeed. Memories of Mozilla.... 31 | self.app_name = app_name 32 | self.app_version = app_version 33 | self.cookie = cookie # FIXME: find out the meaning of this magic value. Why not 3 or 5? 34 | self.request_id = str(uuid.uuid4()).upper() 35 | 36 | def _format_date(self, date=None, datetime=datetime.datetime.now()): 37 | if date == None: 38 | return datetime.strftime("%Y%m%d%H%M%S") 39 | else: 40 | return date.strftime("%Y%m%d") 41 | 42 | def _message(self, institution, username, password, body): 43 | """Composes a complete OFX message document.""" 44 | return DOCUMENT(self._header(), 45 | OFX(self._sign_on(institution, username, password), 46 | body)) 47 | 48 | def _header(self): 49 | """Formats an OFX message header.""" 50 | return HEADER( 51 | OFXHEADER("100"), 52 | DATA("OFXSGML"), 53 | VERSION("102"), 54 | SECURITY("NONE"), 55 | ENCODING("USASCII"), 56 | CHARSET("1252"), 57 | COMPRESSION("NONE"), 58 | OLDFILEUID("NONE"), 59 | NEWFILEUID(self.request_id)) 60 | 61 | def _sign_on(self, institution, username, password): 62 | """Formats an OFX sign-on block.""" 63 | return SIGNONMSGSRQV1( 64 | SONRQ( 65 | DTCLIENT(self._format_date()), 66 | USERID(username), 67 | USERPASS(password), 68 | LANGUAGE("ENG"), 69 | FI( 70 | ORG(institution.ofx_org), 71 | FID(institution.ofx_fid)), 72 | APPID(self.app_name), 73 | APPVER(self.app_version))) 74 | 75 | def fi_profile(self, institution, username, password): 76 | return self._message(institution, username, password, 77 | PROFMSGSRQV1( 78 | PROFTRNRQ( 79 | TRNUID(self.request_id), 80 | CLTCOOKIE(self.cookie), 81 | PROFRQ( 82 | CLIENTROUTING("NONE"), 83 | DTPROFUP("19980101"))))) 84 | 85 | def account_info(self, institution, username, password): 86 | """Returns a complete OFX account information request document.""" 87 | return self._message(institution, username, password, 88 | SIGNUPMSGSRQV1( 89 | ACCTINFOTRNRQ( 90 | TRNUID(self.request_id), 91 | CLTCOOKIE(self.cookie), 92 | ACCTINFORQ( 93 | DTACCTUP("19980101"))))) 94 | 95 | def bank_stmt(self, account, username, password, daysago=90): 96 | """Returns a complete OFX bank statement request document.""" 97 | dt_start = datetime.datetime.now() - datetime.timedelta(days=daysago) 98 | return self._message(account.institution, username, password, 99 | BANKMSGSRQV1( 100 | STMTTRNRQ( 101 | TRNUID(self.request_id), 102 | CLTCOOKIE(self.cookie), 103 | STMTRQ( 104 | BANKACCTFROM( 105 | BANKID(account.aba_number), 106 | ACCTID(account.acct_number), 107 | ACCTTYPE(account.get_ofx_accttype())), 108 | INCTRAN( 109 | DTSTART(self._format_date(date=dt_start)), 110 | INCLUDE("Y")))))) 111 | 112 | def bank_closing(self, account, username, password): 113 | """Returns a complete OFX bank closing information request document.""" 114 | return self._message(account.institution, username, password, 115 | BANKMSGSRQV1( 116 | STMTENDTRNRQ( 117 | TRNUID(self.request_id), 118 | CLTCOOKIE(self.cookie), 119 | STMTENDRQ( 120 | BANKACCTFROM( 121 | BANKID(account.aba_number), 122 | ACCTID(account.acct_number), 123 | ACCTTYPE(account.get_ofx_accttype())))))) 124 | 125 | def creditcard_stmt(self, account, username, password, daysago=90): 126 | """Returns a complete OFX credit card statement request document.""" 127 | dt_start = datetime.datetime.now() - datetime.timedelta(days=daysago) 128 | return self._message(account.institution, username, password, 129 | CREDITCARDMSGSRQV1( 130 | CCSTMTTRNRQ( 131 | TRNUID(self.request_id), 132 | CLTCOOKIE(self.cookie), 133 | CCSTMTRQ( 134 | CCACCTFROM( 135 | ACCTID(account.acct_number)), 136 | INCTRAN( 137 | DTSTART(self._format_date(date=dt_start)), 138 | INCLUDE("Y")))))) 139 | 140 | def creditcard_closing(self, account, username, password): 141 | """Returns a complete OFX credit card closing information request document.""" 142 | dt_start = datetime.datetime.now() - datetime.timedelta(days=61) 143 | dt_end = datetime.datetime.now() - datetime.timedelta(days=31) 144 | return self._message(account.institution, username, password, 145 | CREDITCARDMSGSRQV1( 146 | CCSTMTENDTRNRQ( 147 | TRNUID(self.request_id), 148 | CLTCOOKIE(self.cookie), 149 | CCSTMTENDRQ( 150 | CCACCTFROM( 151 | ACCTID(account.acct_number)), 152 | DTSTART(self._format_date(date=dt_end)), 153 | DTEND(self._format_date(date=dt_end)))))) 154 | 155 | 156 | -------------------------------------------------------------------------------- /fixofx/ofxtools/qif_parser.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | # Copyright 2005-2010 Wesabe, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # 17 | # ofx.QifParser - comprehend the mess that is QIF. 18 | # 19 | 20 | from pyparsing import (CaselessLiteral, Group, LineEnd, 21 | oneOf, OneOrMore, Or, restOfLine, 22 | White, ZeroOrMore) 23 | 24 | from fixofx.ofxtools import _ofxtoolsStartDebugAction, _ofxtoolsSuccessDebugAction, _ofxtoolsExceptionDebugAction 25 | 26 | 27 | class QifParser: 28 | def __init__(self, debug=False): 29 | account_items = { 'N' : "Name", 30 | 'T' : "AccountType", 31 | 'D' : "Description", 32 | 'L' : "CreditLimit", 33 | 'X' : "UnknownField", 34 | 'B' : "Balance", 35 | '/' : "BalanceDate", 36 | '$' : "Balance" } 37 | 38 | noninvestment_items = { 'D' : "Date", 39 | 'T' : "Amount", 40 | 'U' : "Amount2", 41 | 'C' : "Cleared", 42 | 'N' : "Number", 43 | 'P' : "Payee", 44 | 'M' : "Memo", 45 | 'L' : "Category", 46 | 'A' : "Address", 47 | 'S' : "SplitCategory", 48 | 'E' : "SplitMemo", 49 | '$' : "SplitAmount", 50 | '-' : "NegativeSplitAmount" } 51 | 52 | investment_items = { 'D' : "Date", 53 | 'N' : "Action", 54 | 'Y' : "Security", 55 | 'I' : "Price", 56 | 'Q' : "Quantity", 57 | 'T' : "Amount", 58 | 'C' : "Cleared", 59 | 'P' : "Text", 60 | 'M' : "Memo", 61 | 'O' : "Commission", 62 | 'L' : "TransferAccount", 63 | '$' : "TransferAmount" } 64 | 65 | category_items = { 'N' : "Name", 66 | 'D' : "Description", 67 | 'T' : "TaxRelated", 68 | 'I' : "IncomeCategory", 69 | 'E' : "ExpenseCategory", 70 | 'B' : "BudgetAmount", 71 | 'R' : "TaxSchedule" } 72 | 73 | class_items = { 'N' : "Name", 74 | 'D' : "Description" } 75 | 76 | options = Group(CaselessLiteral('!Option:') + restOfLine).suppress() 77 | 78 | banktxns = Group(CaselessLiteral('!Type:Bank').suppress() + 79 | ZeroOrMore(Or([self._items(noninvestment_items), 80 | options])) 81 | ).setResultsName("BankTransactions") 82 | 83 | cashtxns = Group(CaselessLiteral('!Type:Cash').suppress() + 84 | ZeroOrMore(Or([self._items(noninvestment_items), 85 | options])) 86 | ).setResultsName("CashTransactions") 87 | 88 | ccardtxns = Group(Or([CaselessLiteral('!Type:CCard').suppress(), 89 | CaselessLiteral('!Type!CCard').suppress()]) + 90 | ZeroOrMore(Or([self._items(noninvestment_items), 91 | options])) 92 | ).setResultsName("CreditCardTransactions") 93 | 94 | liabilitytxns = Group(CaselessLiteral('!Type:Oth L').suppress() + 95 | ZeroOrMore(Or([self._items(noninvestment_items), 96 | options])) 97 | ).setResultsName("CreditCardTransactions") 98 | 99 | invsttxns = Group(CaselessLiteral('!Type:Invst').suppress() + 100 | ZeroOrMore(self._items(investment_items)) 101 | ).setResultsName("InvestmentTransactions") 102 | 103 | acctlist = Group(CaselessLiteral('!Account').suppress() + 104 | ZeroOrMore(Or([self._items(account_items, name="AccountInfo")])) 105 | ).setResultsName("AccountList") 106 | 107 | category = Group(CaselessLiteral('!Type:Cat').suppress() + 108 | ZeroOrMore(self._items(category_items)) 109 | ).setResultsName("CategoryList") 110 | 111 | classlist = Group(CaselessLiteral('!Type:Class').suppress() + 112 | ZeroOrMore(self._items(category_items)) 113 | ).setResultsName("ClassList") 114 | 115 | self.parser = Group(ZeroOrMore(White()).suppress() + 116 | ZeroOrMore(acctlist).suppress() + 117 | OneOrMore(ccardtxns | cashtxns | banktxns | liabilitytxns | invsttxns) + 118 | ZeroOrMore(category | classlist).suppress() + 119 | ZeroOrMore(White()).suppress() 120 | ).setResultsName("QifStatement") 121 | 122 | if (debug): 123 | self.parser.setDebugActions(_ofxtoolsStartDebugAction, 124 | _ofxtoolsSuccessDebugAction, 125 | _ofxtoolsExceptionDebugAction) 126 | 127 | 128 | def _items(self, items, name="Transaction"): 129 | item_list = [] 130 | for (code, name) in items.items(): 131 | item = self._item(code, name) 132 | item_list.append(item) 133 | return Group(OneOrMore(Or(item_list)) + 134 | oneOf('^EUR ^').setResultsName('Currency') + 135 | LineEnd().suppress() 136 | ).setResultsName(name) 137 | 138 | def _item(self, code, name): 139 | return CaselessLiteral(code).suppress() + \ 140 | restOfLine.setResultsName(name) + \ 141 | LineEnd().suppress() 142 | 143 | def parse(self, qif): 144 | return self.parser.parseString(qif) 145 | 146 | -------------------------------------------------------------------------------- /bin/ofxfake.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Copyright 2005-2010 Wesabe, Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # 18 | # ofxfake.py - a quick and ugly hack to generate fake OFX for testing 19 | # 20 | 21 | import os 22 | import os.path 23 | import sys 24 | from fixofx.ofx import Generator 25 | 26 | 27 | def fixpath(filename): 28 | mypath = os.path.dirname(sys._getframe(1).f_code.co_filename) 29 | return os.path.normpath(os.path.join(mypath, filename)) 30 | 31 | from datetime import date 32 | from datetime import timedelta 33 | import random 34 | 35 | def generate_amt(base_amt): 36 | return random.uniform((base_amt * 0.6), (base_amt * 1.4)) 37 | 38 | # How long should this statement be? 39 | 40 | days = 90 41 | end_date = date.today() 42 | 43 | # How much spending should the statement represent? 44 | 45 | income = 85000 46 | take_home_pay = income * .6 47 | paycheck_amt = "%.02f" % (take_home_pay / 26) 48 | daily_income = take_home_pay / 365 49 | 50 | # Assume that people spend their whole income. At least. 51 | 52 | total_spending = daily_income * days 53 | 54 | # How do people usually spend their money? Taken from 55 | # http://www.billshrink.com/blog/consumer-income-spending/ 56 | # The fees number is made up, but seemed appropriate. 57 | 58 | spending_pcts = \ 59 | { "food": 0.101, 60 | "housing": 0.278, 61 | "utility": 0.056, 62 | "clothing": 0.031, 63 | "auto": 0.144, 64 | "health": 0.047, 65 | "entertainment": 0.044, 66 | "gift": 0.020, 67 | "education": 0.016, 68 | "fee": 0.026 } 69 | 70 | # How much do people spend per transaction? This is taken from 71 | # the tag_summaries table in the live database. 72 | 73 | avg_txn_amts = \ 74 | { "auto": -70.77, 75 | "clothing": -58.31, 76 | "education": -62.64, 77 | "entertainment": -30.10, 78 | "fee": -20.95, 79 | "food": -25.52, 80 | "gift": -18.84, 81 | "health": -73.05, 82 | "mortgage": -1168.49, 83 | "rent": -643.30, 84 | "utility": -90.81 } 85 | 86 | # For now, just throw in some merchant names for each tag. Later 87 | # this should come from the merchant_summaries table. 88 | 89 | top_merchants = \ 90 | { "auto": ["Chevron", "Jiffy Lube", "Union 76", "Arco", "Shell", "Pep Boys"], 91 | "clothing": ["Nordstrom", "Banana Republic", "Macy's", "The Gap", "Kenneth Cole", "J. Crew"], 92 | "education": ["Tuition", "Amazon.com", "Registration", "The Crucible", "Campus Books"], 93 | "entertainment": ["AMC Theaters", "Amazon.com", "Netflix", "iTunes Music Store", "Rhapsody", "Metreon Theaters"], 94 | "fee": ["Bank Fee", "Overlimit Fee", "Late Fee", "Interest Fee", "Monthly Fee", "Annual Fee"], 95 | "food": ["Safeway", "Starbucks", "In-N-Out Burger", "Trader Joe's", "Whole Foods", "Olive Garden"], 96 | "gift": ["Amazon.com", "Nordstrom", "Neiman-Marcus", "Apple Store", "K&L Wines"], 97 | "health": ["Dr. Phillips", "Dr. Jackson", "Walgreen's", "Wal-Mart", "Dr. Roberts", "Dr. Martins"], 98 | "mortgage": ["Mortgage Payment"], 99 | "rent": ["Rent Payment"], 100 | "utility": ["AT&T", "Verizon", "PG&E", "Comcast", "Brinks", ""] } 101 | 102 | # Choose a random account type. 103 | accttype = random.choice(['CHECKING', 'CREDITCARD']) 104 | 105 | if accttype == "CREDITCARD": 106 | # Make up a random 16-digit credit card number with a standard prefix. 107 | acctid = "9789" + str(random.randint(000000000000, 999999999999)) 108 | 109 | # Credit card statements don't use bankid. 110 | bankid = None 111 | 112 | # Make up a negative balance. 113 | balance = "%.02f" % generate_amt(-5000) 114 | 115 | else: 116 | # Make up a random 8-digit account number. 117 | acctid = random.randint(10000000, 99999999) 118 | 119 | # Use a fake bankid so it's easy to find fake OFX uploads. 120 | bankid = "987987987" 121 | 122 | # Make up a positive balance. 123 | balance = "%.02f" % generate_amt(1000) 124 | 125 | def generate_transaction(stmt, tag, type, date=None): 126 | if date is None: 127 | days_ago = timedelta(days=random.randint(0, days)) 128 | date = (end_date - days_ago).strftime("%Y%m%d") 129 | 130 | amount = generate_amt(avg_txn_amts[tag]) 131 | txn_amt = "%.02f" % amount 132 | 133 | merchant = random.choice(top_merchants[tag]) 134 | 135 | stmt.add_transaction(date=date, amount=txn_amt, payee=merchant, type=type) 136 | return amount 137 | 138 | 139 | stmt = Generator(fid="9789789", org="FAKEOFX", acctid=acctid, accttype=accttype, 140 | bankid=bankid, availbal=balance, ledgerbal=balance) 141 | 142 | tags = list(spending_pcts.keys()) 143 | tags.remove("housing") 144 | 145 | if accttype == "CREDITCARD": 146 | # Add credit card payments 147 | 148 | payment_days_ago = 0 149 | 150 | while payment_days_ago < days: 151 | payment_days_ago += 30 152 | payment_amt = "%.02f" % generate_amt(1000) 153 | paymentday = (end_date - timedelta(days=payment_days_ago)).strftime("%Y%m%d") 154 | stmt.add_transaction(date=paymentday, amount=payment_amt, payee="Credit Card Payment", type="PAYMENT") 155 | 156 | elif accttype == "CHECKING": 157 | # First deal with income 158 | 159 | pay_days_ago = 0 160 | 161 | while pay_days_ago < days: 162 | pay_days_ago += 15 163 | payday = (end_date - timedelta(days=pay_days_ago)).strftime("%Y%m%d") 164 | stmt.add_transaction(date=payday, amount=paycheck_amt, payee="Payroll", type="DEP") 165 | 166 | # Then deal with housing 167 | 168 | housing_tag = random.choice(["rent", "mortgage"]) 169 | 170 | housing_days_ago = 0 171 | 172 | while housing_days_ago < days: 173 | housing_days_ago += 30 174 | last_housing = (end_date - timedelta(days=housing_days_ago)).strftime("%Y%m%d") 175 | amount = generate_transaction(stmt, housing_tag, "DEBIT") 176 | total_spending -= abs(amount) 177 | 178 | # Now deal with the rest of the tags 179 | 180 | for tag in tags: 181 | tag_spending = total_spending * spending_pcts[tag] 182 | while tag_spending > 0 and total_spending > 0: 183 | amount = generate_transaction(stmt, tag, "DEBIT") 184 | tag_spending -= abs(amount) 185 | total_spending -= abs(amount) 186 | 187 | print(stmt) 188 | -------------------------------------------------------------------------------- /fixofx/ofx/generator.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | # Copyright 2005-2010 Wesabe, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # 17 | # ofx.generator - build up an OFX statement from source data. 18 | # 19 | 20 | from datetime import date 21 | import uuid 22 | 23 | from fixofx.ofx.builder import * 24 | 25 | 26 | class Generator: 27 | def __init__(self, fid="UNKNOWN", org="UNKNOWN", bankid="UNKNOWN", 28 | accttype="UNKNOWN", acctid="UNKNOWN", availbal="0.00", 29 | ledgerbal="0.00", stmtdate=None, curdef="USD", lang="ENG"): 30 | self.fid = fid 31 | self.org = org 32 | self.bankid = bankid 33 | self.accttype = accttype 34 | self.acctid = acctid 35 | self.availbal = availbal 36 | self.ledgerbal = ledgerbal 37 | self.stmtdate = stmtdate 38 | self.curdef = curdef 39 | self.lang = lang 40 | self.txns_by_date = {} 41 | 42 | def add_transaction(self, date=None, amount=None, number=None, 43 | txid=None, type=None, payee=None, memo=None): 44 | txn = Transaction(date=date, amount=amount, number=number, 45 | txid=txid, type=type, payee=payee, memo=memo) 46 | txn_date_list = self.txns_by_date.get(txn.date, []) 47 | txn_date_list.append(txn) 48 | self.txns_by_date[txn.date] = txn_date_list 49 | 50 | def to_ofx1(self): 51 | # Sort transactions and fill in date information. 52 | # OFX transactions appear most recent first, and oldest last. 53 | self.date_list = list(self.txns_by_date.keys()) 54 | self.date_list.sort() 55 | self.date_list.reverse() 56 | 57 | self.startdate = self.date_list[-1] 58 | self.enddate = self.date_list[0] 59 | if self.stmtdate is None: 60 | self.stmtdate = date.today().strftime("%Y%m%d") 61 | 62 | # Generate the OFX statement. 63 | return DOCUMENT(self._ofx_header(), 64 | OFX(self._ofx_signon(), 65 | self._ofx_stmt())) 66 | 67 | def to_str(self): 68 | return self.to_ofx1() 69 | 70 | def __str__(self): 71 | return self.to_ofx1() 72 | 73 | def _ofx_header(self): 74 | return HEADER( 75 | OFXHEADER("100"), 76 | DATA("OFXSGML"), 77 | VERSION("102"), 78 | SECURITY("NONE"), 79 | ENCODING("USASCII"), 80 | CHARSET("1252"), 81 | COMPRESSION("NONE"), 82 | OLDFILEUID("NONE"), 83 | NEWFILEUID("NONE")) 84 | 85 | def _ofx_signon(self): 86 | return SIGNONMSGSRSV1( 87 | SONRS( 88 | STATUS( 89 | CODE("0"), 90 | SEVERITY("INFO"), 91 | MESSAGE("SUCCESS")), 92 | DTSERVER(self.stmtdate), 93 | LANGUAGE(self.lang), 94 | FI( 95 | ORG(self.org), 96 | FID(self.fid)))) 97 | 98 | def _ofx_stmt(self): 99 | if self.accttype == "CREDITCARD": 100 | return CREDITCARDMSGSRSV1( 101 | CCSTMTTRNRS( 102 | TRNUID("0"), 103 | self._ofx_status(), 104 | CCSTMTRS( 105 | CURDEF(self.curdef), 106 | CCACCTFROM( 107 | ACCTID(self.acctid)), 108 | self._ofx_txns(), 109 | self._ofx_ledgerbal(), 110 | self._ofx_availbal()))) 111 | else: 112 | return BANKMSGSRSV1( 113 | STMTTRNRS( 114 | TRNUID("0"), 115 | self._ofx_status(), 116 | STMTRS( 117 | CURDEF(self.curdef), 118 | BANKACCTFROM( 119 | BANKID(self.bankid), 120 | ACCTID(self.acctid), 121 | ACCTTYPE(self.accttype)), 122 | self._ofx_txns(), 123 | self._ofx_ledgerbal(), 124 | self._ofx_availbal()))) 125 | 126 | def _ofx_status(self): 127 | return STATUS( 128 | CODE("0"), 129 | SEVERITY("INFO"), 130 | MESSAGE("SUCCESS")) 131 | 132 | def _ofx_ledgerbal(self): 133 | return LEDGERBAL( 134 | BALAMT(self.ledgerbal), 135 | DTASOF(self.stmtdate)) 136 | 137 | def _ofx_availbal(self): 138 | return AVAILBAL( 139 | BALAMT(self.availbal), 140 | DTASOF(self.stmtdate)) 141 | 142 | def _ofx_txns(self): 143 | txns = "" 144 | 145 | for date in self.date_list: 146 | txn_list = self.txns_by_date[date] 147 | txn_index = len(txn_list) 148 | for txn in txn_list: 149 | txn_date = txn.date 150 | txn_amt = txn.amount 151 | 152 | # Make a synthetic transaction ID using as many 153 | # uniqueness guarantors as possible. 154 | txn.txid = "%s-%s-%s-%s-%s" % (self.org, self.accttype, 155 | txn_date, txn_index, 156 | txn_amt) 157 | txns += txn.to_ofx() 158 | txn_index -= 1 159 | 160 | return BANKTRANLIST( 161 | DTSTART(self.startdate), 162 | DTEND(self.enddate), 163 | txns) 164 | 165 | 166 | # 167 | # ofx.Transaction - clean and format transaction information. 168 | # 169 | 170 | class Transaction: 171 | def __init__(self, date="UNKNOWN", amount="0.00", number=None, 172 | txid=None, type="UNKNOWN", payee="UNKNOWN", memo=None): 173 | self.date = date 174 | self.amount = amount 175 | self.number = number 176 | self.txid = txid 177 | self.type = type 178 | self.payee = payee 179 | self.memo = memo 180 | 181 | def to_ofx(self): 182 | fields = [] 183 | 184 | if self.type is None: 185 | self.type = "DEBIT" 186 | 187 | fields.append(TRNTYPE(self.type)) 188 | fields.append(DTPOSTED(self.date)) 189 | fields.append(TRNAMT(self.amount)) 190 | 191 | if self.number is not None: 192 | fields.append(CHECKNUM(self.number)) 193 | 194 | if self.txid is None: 195 | self.txid = uuid.generate().upper() 196 | 197 | fields.append(FITID(self.txid)) 198 | fields.append(NAME(self.payee)) 199 | 200 | if self.memo is not None: 201 | fields.append(MEMO(self.memo)) 202 | 203 | return STMTTRN(*fields) 204 | 205 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | fixofx 3 | ====== 4 | 5 | .. image:: https://travis-ci.org/henriquebastos/fixofx.svg?branch=master 6 | :target: https://travis-ci.org/henriquebastos/fixofx 7 | :alt: Build Status 8 | 9 | .. image:: https://landscape.io/github/henriquebastos/fixofx/master/landscape.png 10 | :target: https://landscape.io/github/henriquebastos/fixofx/master 11 | :alt: Code Health 12 | 13 | .. image:: https://pypip.in/v/fixofx/badge.png 14 | :target: https://crate.io/packages/fixofx/ 15 | :alt: Latest PyPI version 16 | 17 | .. image:: https://pypip.in/d/fixofx/badge.png 18 | :target: https://crate.io/packages/fixofx/ 19 | :alt: Number of PyPI downloads 20 | 21 | Canonicalize various financial data file formats to OFX 2 (a.k.a XML) 22 | --------------------------------------------------------------------- 23 | 24 | *Fixofx* is a library and an utility to canonicalizes various financial data file 25 | formats to OFX 2, which is an XML format and hence a lot easier for other code 26 | to deal with. It recognizes OFX 1.x, OFX 2.x, QFX, QIF, and OFC. 27 | 28 | Pipe a data file to ``ofxfix.py``, or specify an input file with the ``-f`` flag, and 29 | if the file is successfully parsed, an OFX 2 file with equivalent data will 30 | be output. 31 | 32 | Various parts of *Fixofx* go through contortions to try to interpret ambiguous 33 | or malformed data, both of which are very common when importing bank data 34 | files. QIF, in particular, is an extremely irregular file format, and *Fixofx* 35 | makes best efforts but will not cover all cases. Also, some international 36 | formats are recognized and interpreted, such as British versus US date 37 | formats, but more work could be done on this. 38 | 39 | Sometimes a data file will not contain information that is important for OFX -- 40 | for instance, neither OFC nor QIF include the OFX "FID" and "ORG" fields. Other times, 41 | the data format will include this data, but inconsistently, such as QIF's account 42 | type, which can be ambiguous or absent. In these cases you can ask the user to 43 | provide hints to *Fixofx*, and convey those hints via command-line options (see 44 | `Command line operation`_, below). 45 | 46 | The *Fixofx* project also includes ``ofxfake.py``, a utility script to generate fake 47 | OFX files for testing purposes. 48 | 49 | Installation 50 | ------------ 51 | 52 | :: 53 | 54 | pip install fixofx 55 | 56 | This package only works on Python 3+. 57 | 58 | Tests 59 | ----- 60 | 61 | A partial test suite is included. Run it as follows:: 62 | 63 | git clone https://github.com/henriquebastos/fixofx.git 64 | pip install -r requirements-dev.txt 65 | py.test 66 | 67 | Command line operation 68 | ---------------------- 69 | 70 | The simplest invocation of the script is:: 71 | 72 | ofxfix.py -f 73 | 74 | You can also pipe a data file to standard input -- that is, this invocation 75 | is equivalent to the above:: 76 | 77 | ofxfix.py < 78 | 79 | There are several command line options, as follows:: 80 | 81 | -h, --help show this help message and exit 82 | -d, --debug spit out gobs of debugging output during parse 83 | -v, --verbose be more talkative, social, outgoing 84 | -t, --type print input file type and exit 85 | -f FILENAME, --file=FILENAME source file to convert (writes to STDOUT) 86 | --fid=FID (OFC/QIF only) FID to use in output 87 | --org=ORG (OFC/QIF only) ORG to use in output 88 | --curdef=CURDEF (OFC/QIF only) Currency identifier to use in output 89 | --lang=LANG (OFC/QIF only) Language identifier to use in output 90 | --bankid=BANKID (QIF only) Routing number to use in output 91 | --accttype=ACCTTYPE (QIF only) Account type to use in output 92 | --acctid=ACCTID (QIF only) Account number to use in output 93 | --balance=BALANCE (QIF only) Account balance to use in output 94 | --dayfirst (QIF only) Parse dates day first (UK format) 95 | 96 | Debugging 97 | --------- 98 | 99 | If you find a data file fixofx can't parse, try running with the ``-v`` flag, 100 | and if that doesn't help (which it probably won't), try the ``-d`` flag, too. 101 | 102 | Most of the time a failed parse is due to a malformed data file. QIF, 103 | especially, is damn near undocumented, and different banks just seem to make 104 | stuff up about how they think it should work. And they don't talk to each 105 | other about their crazy QIF theories, either. So that's bad. 106 | 107 | If you think the script is basically working (e.g., tests pass) but a parse is 108 | failing, the best thing to do is to just look at the data file and see how it 109 | is different from other examples you've seen. Post a cleaned-up (sensitive 110 | data removed) snippet as a gist if you want someone else to help. Usually a 111 | difference will jump out at you after a while if you're familiar with the 112 | format. 113 | 114 | ofxfake.py 115 | ---------- 116 | 117 | The ``ofxfake.py`` script generates real-ish-seeming OFX for testing and demo 118 | purposes. You can generate a few fake OFX files using the script, and upload 119 | them to Wesabe to try it out or demonstrate it without showing your real 120 | account data to anyone. 121 | 122 | The script uses some real demographic data to make the fake transactions it 123 | lists look real, but otherwise it isn't at all sophisticated. It will randomly 124 | choose to generate a checking or credit card statement and has no options. 125 | 126 | Contributing 127 | ------------ 128 | 129 | Contributions to *Fixofx* are welcome. Please add tests for your contribution 130 | and make sure all tests pass before sending a pull request. Here are some 131 | ideas for things to do: 132 | 133 | * fakeofx could use some command line options and a little more control over 134 | the output. **(EASY)** 135 | * The OFX parser class has some ugly regular expression hacks added to deal 136 | with a variety of malformed OFX inputs. Each new regex makes things slower 137 | and makes the baby jwz cry. Find a better path. **(EASY)** 138 | * Fill in missing tests, especially in QIF conversion. **(MEDIUM)** 139 | * *Fixofx* currently converts QIF to OFX/1, and then OFX/1 to OFX/2, which is 140 | totally crazy-pants and makes everything ungodly slow. Go straight from QIF 141 | to OFX/2 instead. **(MEDIUM)** 142 | * Some people would be happy if *Fixofx* accepted a bunch of input formats (as 143 | it does) and had options for outputing any of those formats, too (right now 144 | OFX/2 output is the only option). Basically, convert everything to an 145 | internal representation and then output whatever kind of document the user 146 | wants. **(MEDIUM)** 147 | * The date format parsing could be a lot more intelligent, using windows of 148 | transactions to guess the date format instead of requiring at least one 149 | unambiguous date. **(MEDIUM)** 150 | * There is the start of a CSV converter in ``ofxtools``. This has to be one of 151 | the most-requested Wesabe features evar. Have at it. **(HARD)** 152 | 153 | Thanks 154 | ------ 155 | 156 | This project was created by devs at Wasabe Inc. 157 | 158 | Patches were contributed by `James Nylen `_ and `Jeremy Milum `_. 159 | 160 | Many, many, many fixes were contributed by `Vanderson Mota `_. 161 | 162 | Packaging and conversion to Python 3 was made by `Henrique Bastos `_. 163 | 164 | License 165 | ------- 166 | 167 | Apache License 2.0 -------------------------------------------------------------------------------- /fixofx/ofx/response.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | # Copyright 2005-2010 Wesabe, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # 17 | # ofx.response - access to contents of an OFX response document. 18 | # 19 | from fixofx.ofx import Document, Parser, Account, Error 20 | 21 | 22 | class Response(Document): 23 | def __init__(self, response, debug=False): 24 | # Bank of America (California) seems to be putting out bad Content-type 25 | # headers on manual OFX download. I'm special-casing this out since 26 | # B of A is such a large bank. 27 | # REVIEW: Check later to see if this is still needed, espcially once 28 | # B of A is mechanized. 29 | # REVIEW: Checked. Still needed. Feh! 30 | if not isinstance(response, str): 31 | response = response.decode('utf-8') 32 | 33 | self.raw_response = response 34 | self.raw_response = self.raw_response.replace('Content- type:application/ofx', "") 35 | 36 | # Good god, another one. Regex? 37 | self.raw_response = self.raw_response.replace('Content-Type: application/x-ofx', "") 38 | 39 | # I'm seeing this a lot, so here's an ugly workaround. I wonder why multiple 40 | # FIs are causing it, though. 41 | self.raw_response = self.raw_response.replace('****OFX download terminated due to exception: Null or zero length FITID****', '') 42 | 43 | parser = Parser(debug) 44 | self.parse_dict = parser.parse(self.raw_response) 45 | self.ofx = self.parse_dict["body"]["OFX"][0].asDict() 46 | 47 | def as_dict(self): 48 | return self.ofx 49 | 50 | def as_string(self): 51 | return self.raw_response 52 | 53 | def get_encoding(self): 54 | return self.parse_dict["header"]["ENCODING"] 55 | 56 | def get_statements(self): 57 | # This allows us to parse out all statements from an OFX file 58 | # that contains multiple statements. 59 | 60 | # FIXME: I'm not positive this is legitimate. Are there tagsets 61 | # a bank might use inside a bank or creditcard response *other* 62 | # than statements? I bet there are. 63 | statements = [] 64 | for tag in list(self.ofx.keys()): 65 | if tag == "BANKMSGSRSV1" or tag == "CREDITCARDMSGSRSV1": 66 | for sub_tag in self.ofx[tag]: 67 | statements.append(Statement(sub_tag)) 68 | return statements 69 | 70 | def get_accounts(self): 71 | accounts = [] 72 | for tag in list(self.ofx.keys()): 73 | if tag == "SIGNUPMSGSRSV1": 74 | signup = self.ofx[tag].asDict() 75 | for signup_tag in signup: 76 | if signup_tag == "ACCTINFOTRNRS": 77 | accttrns = signup[signup_tag].asDict() 78 | for accttrns_tag in accttrns: 79 | if accttrns_tag == "ACCTINFORS": 80 | acctrs = accttrns[accttrns_tag] 81 | for acct in acctrs: 82 | if acct[0] == "ACCTINFO": 83 | account = self._extract_account(acct) 84 | if account is not None: 85 | accounts.append(account) 86 | return accounts 87 | 88 | def _extract_account(self, acct_block): 89 | acct_dict = acct_block.asDict() 90 | 91 | if "DESC" in acct_dict: 92 | desc = acct_dict["DESC"] 93 | else: 94 | desc = None 95 | 96 | if "BANKACCTINFO" in acct_dict: 97 | acctinfo = acct_dict["BANKACCTINFO"] 98 | return Account(ofx_block=acctinfo["BANKACCTFROM"], desc=desc) 99 | 100 | elif "CCACCTINFO" in acct_dict: 101 | acctinfo = acct_dict["CCACCTINFO"] 102 | account = Account(ofx_block=acctinfo["CCACCTFROM"], desc=desc) 103 | account.acct_type = "CREDITCARD" 104 | return account 105 | 106 | else: 107 | return None 108 | 109 | def check_signon_status(self): 110 | status = self.ofx["SIGNONMSGSRSV1"]["SONRS"]["STATUS"] 111 | # This will throw an ofx.Error if the signon did not succeed. 112 | self._check_status(status, "signon") 113 | # If no exception was thrown, the signon succeeded. 114 | return True 115 | 116 | def _check_status(self, status_block, description): 117 | # Convert the PyParsing result object into a dictionary so we can 118 | # provide default values if the status values don't exist in the 119 | # response. 120 | status = status_block.asDict() 121 | 122 | # There is no OFX status code "-1," so I'm using that code as a 123 | # marker for "No status code was returned." 124 | code = status.get("CODE", "-1") 125 | 126 | # Code "0" is "Success"; code "1" is "data is up-to-date." Anything 127 | # else represents an error. 128 | if code is not "0" and code is not "1": 129 | # Try to find information about the error. If the bank didn't 130 | # provide status information, return the value "NONE," which 131 | # should be both clear to a user and a marker of a lack of 132 | # information from the bank. 133 | severity = status.get("SEVERITY", "NONE") 134 | message = status.get("MESSAGE", "NONE") 135 | 136 | # The "description" allows the code to give some indication 137 | # of where the error originated (for instance, the kind of 138 | # account we were trying to download when the error occurred). 139 | error = Error(description, code, severity, message) 140 | raise error 141 | 142 | 143 | class Statement(Document): 144 | def __init__(self, statement): 145 | self.parse_result = statement 146 | self.parse_dict = self.parse_result.asDict() 147 | 148 | if "STMTRS" in self.parse_dict: 149 | stmt = self.parse_dict["STMTRS"] 150 | self.account = Account(ofx_block=stmt["BANKACCTFROM"]) 151 | elif "CCSTMTRS" in self.parse_dict: 152 | stmt = self.parse_dict["CCSTMTRS"] 153 | self.account = Account(ofx_block=stmt["CCACCTFROM"]) 154 | self.account.acct_type = "CREDITCARD" 155 | else: 156 | error = ValueError("Unknown statement type: %s." % statement) 157 | raise error 158 | 159 | self.currency = self._get(stmt, "CURDEF") 160 | self.begin_date = self._get(stmt["BANKTRANLIST"], "DTSTART") 161 | self.end_date = self._get(stmt["BANKTRANLIST"], "DTEND") 162 | self.balance = self._get(stmt["LEDGERBAL"], "BALAMT") 163 | self.bal_date = self._get(stmt["LEDGERBAL"], "DTASOF") 164 | 165 | def _get(self, data, key): 166 | data_dict = data.asDict() 167 | return data_dict.get(key, "NONE") 168 | 169 | def as_dict(self): 170 | return self.parse_dict 171 | 172 | def as_xml(self, indent=4): 173 | taglist = self.parse_result.asList() 174 | return self._format_xml(taglist, indent) 175 | 176 | def get_account(self): 177 | return self.account 178 | 179 | def get_currency(self): 180 | return self.currency 181 | 182 | def get_begin_date(self): 183 | return self.begin_date 184 | 185 | def get_end_date(self): 186 | return self.end_date 187 | 188 | def get_balance(self): 189 | return self.balance 190 | 191 | def get_balance_date(self): 192 | return self.bal_date 193 | 194 | -------------------------------------------------------------------------------- /fixofx/test/fixtures/empty_tags.ofx: -------------------------------------------------------------------------------- 1 | 2 | 2 3 | 1252 4 | 5 | 0 6 | 20110705182508 7 | 1246577115 8 | 0 9 | 0 10 | 0 11 | 12 | 13 | 0 14 | 4 15 | 16 | ********************************************** 17 | BRADESCO - Mensagens Informativas 18 | ********************************************** 19 | Obrigado por Utilizar o Nosso Sistema 20 | ______________________________________________ 21 | ********************************************** 22 | JANDER SOUSA MARTINS 23 | Agencia 3199 Conta 32306 24 | ********************************************** 25 | 04/07 101 BX AUT APLIC 0040711 13893.58 26 | 27 | 28 | 29 | 0 30 | 4 31 | 32 | 33 | 34 | 35 | 1 36 | 0 37 | 38 | 20110620 39 | 20110705 40 | -13892.58 41 | 42 | 43 | 0 44 | 20110630 45 | 25225.05 46 | 30062011 7539185 00000 47 | 7539185 48 | 00062 RESG.DE PAPEIS 49 | 50 | 51 | 52 | 53 | 0 54 | 20110630 55 | 5.53 56 | 30062011 0423385 00000 57 | 0423385 58 | 00282 DOC CRED.AUTOM* - EMBRATEL PARTICIPACOES SA 59 | 60 | 61 | 62 | 63 | 1 64 | 20110630 65 | -10830.25 66 | 30062011 0459648 00000 67 | 0459648 68 | 00412 TRANSF AUTORIZ - Marcos Rocha da Fonseca 69 | 70 | 71 | 72 | 73 | 1 74 | 20110630 75 | -3000.00 76 | 30062011 0172591 00000 77 | 0172591 78 | 00521 TRANSF FDOS DOC - DEST.JOSE LUIS BARRA 79 | 80 | 81 | 82 | 83 | 1 84 | 20110630 85 | -705.00 86 | 30062011 0174628 00000 87 | 0174628 88 | 00521 TRANSF FDOS DOC - DEST.FAGNER H DOS SANTOS LINS 89 | 90 | 91 | 92 | 93 | 1 94 | 20110630 95 | -182.00 96 | 30062011 0229765 00000 97 | 0229765 98 | 00521 TRANSF FDOS DOC - DEST.MARILSA TELES GOMES 99 | 100 | 101 | 102 | 103 | 1 104 | 20110630 105 | -7.80 106 | 30062011 0229765 00000 107 | 0229765 108 | 01963 DOC/TEDINTERNET - DOC INTERNET 109 | 110 | 111 | 112 | 113 | 1 114 | 20110630 115 | -10500.00 116 | 30062011 0001508 00000 117 | 0001508 118 | 00999 CHQ COMPENSADO 119 | 120 | 121 | 122 | 123 | 0 124 | 20110701 125 | 182.00 126 | 01072011 0229765 00000 127 | 0229765 128 | 00051 DOC/TED DEVOLV 129 | 130 | 131 | 132 | 133 | 0 134 | 20110701 135 | 60021.11 136 | 01072011 7539185 00000 137 | 7539185 138 | 00062 RESG.DE PAPEIS 139 | 140 | 141 | 142 | 143 | 1 144 | 20110701 145 | -6500.00 146 | 01072011 0001512 00000 147 | 0001512 148 | 00002 CHEQUE - ESPECIE 149 | 150 | 151 | 152 | 153 | 1 154 | 20110701 155 | -455.74 156 | 01072011 0000775 00000 157 | 0000775 158 | 00311 PAGTO COBRANCA - LAGOA ENSINO DE IDIOMAS LTDA LUI 159 | 160 | 161 | 162 | 163 | 1 164 | 20110701 165 | -20000.00 166 | 01072011 0291925 00000 167 | 0291925 168 | 00318 TED-T ELET DISP - DEST.ELEVADORES SANBERG LTDA ME 169 | 170 | 171 | 172 | 173 | 1 174 | 20110701 175 | -250.00 176 | 01072011 2832691 00000 177 | 2832691 178 | 00412 TRANSF AUTORIZ - Rodrigo Sousa Xavier 179 | 180 | 181 | 182 | 183 | 1 184 | 20110701 185 | -210.00 186 | 01072011 0248844 00000 187 | 0248844 188 | 00521 TRANSF FDOS DOC - DEST.marcela giovanini g gadelha 189 | 190 | 191 | 192 | 193 | 1 194 | 20110701 195 | -2000.00 196 | 01072011 0459309 00000 197 | 0459309 198 | 00611 TR.AUT.C/C/POUP - Adilson Nascimento dos Santos 199 | 200 | 201 | 202 | 203 | 1 204 | 20110701 205 | -600.00 206 | 01072011 1899884 00000 207 | 1899884 208 | 00611 TR.AUT.C/C/POUP - Zilmar Azevedo dos Santos 209 | 210 | 211 | 212 | 213 | 1 214 | 20110701 215 | -400.00 216 | 01072011 0001518 00000 217 | 0001518 218 | 00783 CH.PAGO OUTR.AG - DIVERSOS RECEBIMENTOS / 3428 219 | 220 | 221 | 222 | 223 | 1 224 | 20110701 225 | -4833.00 226 | 01072011 0001519 00000 227 | 0001519 228 | 00783 CH.PAGO OUTR.AG - DIVERSOS RECEBIMENTOS / 3176 229 | 230 | 231 | 232 | 233 | 1 234 | 20110701 235 | -7.80 236 | 01072011 0248844 00000 237 | 0248844 238 | 01963 DOC/TEDINTERNET - DOC INTERNET 239 | 240 | 241 | 242 | 243 | 1 244 | 20110701 245 | -4446.00 246 | 01072011 0001467 00000 247 | 0001467 248 | 00996 CHQ COMPENSADO 249 | 250 | 251 | 252 | 253 | 1 254 | 20110701 255 | -4226.10 256 | 01072011 0001466 00000 257 | 0001466 258 | 00999 CHQ COMPENSADO 259 | 260 | 261 | 262 | 263 | 1 264 | 20110701 265 | -1500.00 266 | 01072011 0001468 00000 267 | 0001468 268 | 00999 CHQ COMPENSADO 269 | 270 | 271 | 272 | 273 | 1 274 | 20110701 275 | -1600.00 276 | 01072011 0001469 00000 277 | 0001469 278 | 00999 CHQ COMPENSADO 279 | 280 | 281 | 282 | 283 | 1 284 | 20110701 285 | -4400.00 286 | 01072011 0001511 00000 287 | 0001511 288 | 00999 CHQ COMPENSADO 289 | 290 | 291 | 292 | 293 | 1 294 | 20110701 295 | -5000.00 296 | 01072011 0001516 00000 297 | 0001516 298 | 00999 CHQ COMPENSADO 299 | 300 | 301 | 302 | 303 | 1 304 | 20110701 305 | -3780.00 306 | 01072011 0001520 00000 307 | 0001520 308 | 00999 CHQ COMPENSADO 309 | 310 | 311 | 312 | 313 | 0 314 | 20110704 315 | 210.00 316 | 04072011 0248844 00000 317 | 0248844 318 | 00051 DOC/TED DEVOLV 319 | 320 | 321 | 322 | 323 | 1 324 | 20110704 325 | -5399.34 326 | 04072011 0000771 00000 327 | 0000771 328 | 00311 PAGTO COBRANCA - ALUGUEL VAGA MARINA VEROLME 329 | 330 | 331 | 332 | 333 | 1 334 | 20110704 335 | -222.60 336 | 04072011 0350773 00000 337 | 0350773 338 | 00521 TRANSF FDOS DOC - DEST.MARILSA TELES GOMES 339 | 340 | 341 | 342 | 343 | 1 344 | 20110704 345 | -240.00 346 | 04072011 0375044 00000 347 | 0375044 348 | 00521 TRANSF FDOS DOC - DEST.AMANDA M MAURO 349 | 350 | 351 | 352 | 353 | 1 354 | 20110704 355 | -182.00 356 | 04072011 0381289 00000 357 | 0381289 358 | 00521 TRANSF FDOS DOC - DEST.marilsa teles gomes 359 | 360 | 361 | 362 | 363 | 1 364 | 20110704 365 | -360.00 366 | 04072011 0381735 00000 367 | 0381735 368 | 00521 TRANSF FDOS DOC - DEST.FERNANDA GIOVANINI 369 | 370 | 371 | 372 | 373 | 1 374 | 20110704 375 | -222.60 376 | 04072011 0382274 00000 377 | 0382274 378 | 00521 TRANSF FDOS DOC - DEST.marilsa teles gomes 379 | 380 | 381 | 382 | 383 | 1 384 | 20110704 385 | -250.00 386 | 04072011 1899158 00000 387 | 1899158 388 | 00611 TR.AUT.C/C/POUP - Zilmar Azevedo dos Santos 389 | 390 | 391 | 392 | 393 | 1 394 | 20110704 395 | -6000.00 396 | 04072011 0001513 00000 397 | 0001513 398 | 00999 CHQ COMPENSADO 399 | 400 | 401 | 402 | 403 | 1 404 | 20110704 405 | -1227.04 406 | 04072011 1433341 00000 407 | 1433341 408 | 00932 CONTA TELEFONE - VIVO RJ-01214333416 409 | 410 | 411 | 412 | 413 | 414 | -------------------------------------------------------------------------------- /fixofx/test/test_ofxtools_qif_converter.py: -------------------------------------------------------------------------------- 1 | # Copyright 2005-2010 Wesabe, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import textwrap 15 | import unittest 16 | from time import localtime, strftime 17 | 18 | from fixofx.ofxtools.qif_converter import QifConverter 19 | 20 | 21 | class QifConverterTests(unittest.TestCase): 22 | def test_bank_stmttype(self): 23 | qiftext = textwrap.dedent('''\ 24 | !Type:Bank 25 | D01/13/2005 26 | ^ 27 | ''') 28 | converter = QifConverter(qiftext) 29 | self.assertEqual(converter.accttype, "CHECKING") 30 | 31 | def test_ccard_stmttype(self): 32 | qiftext = textwrap.dedent('''\ 33 | !Type:CCard 34 | D01/13/2005 35 | ^ 36 | ''') 37 | converter = QifConverter(qiftext) 38 | self.assertEqual(converter.accttype, "CREDITCARD") 39 | 40 | def test_no_stmttype(self): 41 | qiftext = textwrap.dedent('''\ 42 | D01/13/2005 43 | ^ 44 | ''') 45 | converter = QifConverter(qiftext) 46 | self.assertEqual(converter.accttype, "CHECKING") 47 | 48 | def test_no_txns(self): 49 | qiftext = textwrap.dedent('''\ 50 | !Type:Bank 51 | ''') 52 | today = strftime("%Y%m%d", localtime()) 53 | converter = QifConverter(qiftext) 54 | self.assertEqual(converter.start_date, today) 55 | self.assertEqual(converter.end_date, today) 56 | 57 | def test_us_date(self): 58 | qiftext = textwrap.dedent('''\ 59 | !Type:Bank 60 | D01/13/2005 61 | ^ 62 | ''') 63 | converter = QifConverter(qiftext) 64 | self.assertTrue("20050113" in converter.txns_by_date) 65 | 66 | def test_uk_date(self): 67 | qiftext = textwrap.dedent('''\ 68 | !Type:Bank 69 | D13/01/2005 70 | ^ 71 | ''') 72 | converter = QifConverter(qiftext) 73 | self.assertTrue("20050113" in converter.txns_by_date) 74 | 75 | def test_ambiguous_date(self): 76 | qiftext = textwrap.dedent('''\ 77 | !Type:Bank 78 | D12/01/2005 79 | ^ 80 | ''') 81 | converter = QifConverter(qiftext) 82 | self.assertTrue("20051201" in converter.txns_by_date) 83 | 84 | def test_mixed_us_dates(self): 85 | qiftext = textwrap.dedent('''\ 86 | !Type:Bank 87 | D01/12/2005 88 | ^ 89 | D01/13/2005 90 | ^ 91 | ''') 92 | converter = QifConverter(qiftext) 93 | self.assertTrue("20050112" in converter.txns_by_date) 94 | self.assertTrue("20050113" in converter.txns_by_date) 95 | 96 | def test_mixed_uk_dates(self): 97 | qiftext = textwrap.dedent('''\ 98 | !Type:Bank 99 | D12/01/2005 100 | ^ 101 | D13/01/2005 102 | ^ 103 | ''') 104 | converter = QifConverter(qiftext) 105 | self.assertTrue("20050112" in converter.txns_by_date) 106 | self.assertTrue("20050113" in converter.txns_by_date) 107 | 108 | def test_slashfree_date(self): 109 | qiftext = textwrap.dedent('''\ 110 | !Type:Bank 111 | D12012005 112 | ^ 113 | ''') 114 | converter = QifConverter(qiftext) 115 | self.assertTrue("20051201" in converter.txns_by_date) 116 | 117 | def test_unparseable_date(self): 118 | qiftext = textwrap.dedent('''\ 119 | !Type:Bank 120 | DFnargle 121 | ^ 122 | ''') 123 | self.assertRaises(ValueError, QifConverter, qiftext) 124 | 125 | def test_len_eight_no_int_date(self): 126 | qiftext = textwrap.dedent('''\ 127 | !Type:Bank 128 | DAAAAAAAA 129 | ^ 130 | ''') 131 | self.assertRaises(ValueError, QifConverter, qiftext) 132 | 133 | def test_asc_dates(self): 134 | qiftext = textwrap.dedent('''\ 135 | !Type:Bank 136 | D01/13/2005 137 | ^ 138 | D01/27/2005 139 | ^ 140 | D02/01/2005 141 | ^ 142 | D02/01/2005 143 | ^ 144 | D02/13/2005 145 | ^ 146 | ''') 147 | converter = QifConverter(qiftext) 148 | self.assertEqual(converter.start_date, "20050113") 149 | self.assertEqual(converter.end_date, "20050213") 150 | self.assertEqual(len(list(converter.txns_by_date.keys())), 4) 151 | 152 | def test_desc_dates(self): 153 | qiftext = textwrap.dedent('''\ 154 | !Type:Bank 155 | D02/13/2005 156 | ^ 157 | D02/01/2005 158 | ^ 159 | D02/01/2005 160 | ^ 161 | D01/27/2005 162 | ^ 163 | D01/13/2005 164 | ^ 165 | ''') 166 | converter = QifConverter(qiftext) 167 | self.assertEqual(converter.start_date, "20050113") 168 | self.assertEqual(converter.end_date, "20050213") 169 | self.assertEqual(len(list(converter.txns_by_date.keys())), 4) 170 | 171 | def test_mixed_dates(self): 172 | qiftext = textwrap.dedent('''\ 173 | !Type:Bank 174 | D02/01/2005 175 | ^ 176 | D02/13/2005 177 | ^ 178 | D01/13/2005 179 | ^ 180 | D02/01/2005 181 | ^ 182 | D01/27/2005 183 | ^ 184 | ''') 185 | converter = QifConverter(qiftext) 186 | self.assertEqual(converter.start_date, "20050113") 187 | self.assertEqual(converter.end_date, "20050213") 188 | self.assertEqual(len(list(converter.txns_by_date.keys())), 4) 189 | 190 | def test_default_currency(self): 191 | qiftext = textwrap.dedent('''\ 192 | !Type:Bank 193 | D01/25/2007 194 | T417.93 195 | ^ 196 | ''') 197 | converter = QifConverter(qiftext) 198 | ofx102 = converter.to_ofx102() 199 | self.assertTrue(ofx102.find('USD') != -1) 200 | 201 | def test_found_currency(self): 202 | qiftext = textwrap.dedent('''\ 203 | !Type:Bank 204 | D01/25/2007 205 | T417.93 206 | ^EUR 207 | ''') 208 | converter = QifConverter(qiftext) 209 | ofx102 = converter.to_ofx102() 210 | self.assertTrue(ofx102.find('EUR') != -1) 211 | 212 | def test_explicit_currency(self): 213 | qiftext = textwrap.dedent('''\ 214 | !Type:Bank 215 | D01/25/2007 216 | T417.93 217 | ^ 218 | ''') 219 | converter = QifConverter(qiftext, curdef='GBP') 220 | ofx102 = converter.to_ofx102() 221 | self.assertTrue(ofx102.find('GBP') != -1) 222 | 223 | def test_amount2(self): 224 | qiftext = textwrap.dedent('''\ 225 | !Type:Bank 226 | D02/01/2005 227 | U25.42 228 | ^ 229 | ''') 230 | converter = QifConverter(qiftext) 231 | txn = converter.txns_by_date["20050201"][0] 232 | self.assertEqual(txn["Amount"], "25.42") 233 | 234 | def test_bad_amount_precision(self): 235 | qiftext = textwrap.dedent('''\ 236 | !Type:Bank 237 | D01/25/2007 238 | T417.930 239 | ^ 240 | ''') 241 | converter = QifConverter(qiftext) 242 | txn = converter.txns_by_date["20070125"][0] 243 | self.assertEqual(txn["Amount"], "417.93") 244 | 245 | def test_dash_amount(self): 246 | qiftext = textwrap.dedent('''\ 247 | !Type:Bank 248 | D02/01/2005 249 | T25.42 250 | ^ 251 | D02/01/2005 252 | T- 253 | ^ 254 | ''') 255 | converter = QifConverter(qiftext) 256 | txn_list = converter.txns_by_date["20050201"] 257 | self.assertEqual(len(txn_list), 1) 258 | txn = txn_list[0] 259 | self.assertEqual(txn["Amount"], "25.42") 260 | 261 | def test_trailing_minus(self): 262 | qiftext = textwrap.dedent('''\ 263 | !Type:Bank 264 | D08/06/2008 265 | T26.24- 266 | ^ 267 | ''') 268 | converter = QifConverter(qiftext) 269 | txn = converter.txns_by_date["20080806"][0] 270 | self.assertEqual(txn["Amount"], "-26.24") 271 | 272 | def test_n_a_number(self): 273 | qiftext = textwrap.dedent('''\ 274 | !Type:Bank 275 | D01/25/2007 276 | T417.93 277 | NN/A 278 | ^ 279 | ''') 280 | converter = QifConverter(qiftext) 281 | txn = converter.txns_by_date["20070125"][0] 282 | self.assertEqual("Number" in txn, False) 283 | 284 | def test_creditcard_number(self): 285 | qiftext = textwrap.dedent('''\ 286 | !Type:Bank 287 | D01/25/2007 288 | T417.93 289 | NXXXX-XXXX-XXXX-1234 290 | ^ 291 | ''') 292 | converter = QifConverter(qiftext) 293 | txn = converter.txns_by_date["20070125"][0] 294 | self.assertEqual("Number" in txn, False) 295 | 296 | def test_creditcard_stmt_number(self): 297 | qiftext = textwrap.dedent('''\ 298 | !Type:CCard 299 | D01/25/2007 300 | T417.93 301 | N1234 302 | ^ 303 | ''') 304 | converter = QifConverter(qiftext) 305 | txn = converter.txns_by_date["20070125"][0] 306 | self.assertEqual("Number" in txn, False) 307 | 308 | def test_check_stmt_number(self): 309 | qiftext = textwrap.dedent('''\ 310 | !Type:Bank 311 | D01/25/2007 312 | T417.93 313 | N1234 314 | ^ 315 | ''') 316 | converter = QifConverter(qiftext) 317 | txn = converter.txns_by_date["20070125"][0] 318 | self.assertEqual(txn.get("Type"), "CHECK") 319 | 320 | if __name__ == '__main__': 321 | unittest.main() 322 | -------------------------------------------------------------------------------- /fixofx/ofx/client.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | # Copyright 2005-2010 Wesabe, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # 17 | # ofx.client - user agent for sending OFX requests and checking responses. 18 | # 19 | import urllib.request 20 | import urllib.error 21 | import urllib.parse 22 | 23 | from fixofx.ofx import Request, Error, Response 24 | 25 | 26 | class Client: 27 | """Network client for communicating with OFX servers. The client 28 | handles forming a valid OFX request document, transmiting that 29 | request to the named OFX server, parsing the server's response for 30 | error flags and throwing errors as exceptions, and returning the 31 | requested OFX document if the request was successful.""" 32 | 33 | def __init__(self): 34 | """Constructs the Client object. No configuration options 35 | are offered.""" 36 | # FIXME: Need to let the client set itself for OFX 1.02 or OFX 2.0 formatting. 37 | self.request_msg = None 38 | 39 | def get_fi_profile(self, institution, 40 | username="anonymous00000000000000000000000", 41 | password="anonymous00000000000000000000000"): 42 | request = Request() 43 | self.request_msg = request.fi_profile(institution, username, password) 44 | return self._send_request(institution.ofx_url, self.request_msg) 45 | 46 | def get_account_info(self, institution, username, password): 47 | request = Request() 48 | self.request_msg = request.account_info(institution, username, password) 49 | return self._send_request(institution.ofx_url, self.request_msg) 50 | 51 | def get_statement(self, account, username, password): 52 | acct_type = account.get_ofx_accttype() 53 | if acct_type == "CREDITCARD": 54 | return self.get_creditcard_statement(account, username, password) 55 | elif acct_type == "CHECKING" or acct_type == "SAVINGS" \ 56 | or acct_type == "MONEYMRKT" or acct_type == "MONEYMARKT" or acct_type == "CREDITLINE": 57 | return self.get_bank_statement(account, username, password) 58 | else: 59 | raise ValueError("Unknown account type '%s'." % acct_type) 60 | 61 | def get_bank_statement(self, account, username, password): 62 | """Sends an OFX request for the given user's bank account 63 | statement, and returns that statement as an OFX document if 64 | the request is successful.""" 65 | request = Request() 66 | # I'm breaking out these retries by statement type since I'm assuming that bank, 67 | # credit card, and investment OFX servers may each have different behaviors. 68 | try: 69 | # First, try to get a statement for the full year. The USAA and American Express 70 | # OFX servers return a valid statement, although USAA only includes 90 days and 71 | # American Express seems to only include back to the first of the year. 72 | self.request_msg = request.bank_stmt(account, username, password, daysago=365) 73 | return self._send_request(account.institution.ofx_url, self.request_msg) 74 | except Error as detail: 75 | try: 76 | # If that didn't work, try 90 days back. 77 | self.request_msg = request.bank_stmt(account, username, password, daysago=90) 78 | return self._send_request(account.institution.ofx_url, self.request_msg) 79 | except Error as detail: 80 | # If that also didn't work, try 30 days back, which has been our default and 81 | # which always seems to work across all OFX servers. 82 | self.request_msg = request.bank_stmt(account, username, password, daysago=30) 83 | return self._send_request(account.institution.ofx_url, self.request_msg) 84 | 85 | def get_creditcard_statement(self, account, username, password): 86 | """Sends an OFX request for the given user's credit card 87 | statement, and returns that statement if the request is 88 | successful. If the OFX server returns an error, the client 89 | will throw an OfxException indicating the error code and 90 | message.""" 91 | # See comments in get_bank_statement, above, which explain these try/catch 92 | # blocks. 93 | request = Request() 94 | try: 95 | self.request_msg = request.creditcard_stmt(account, username, password, daysago=365) 96 | return self._send_request(account.institution.ofx_url, self.request_msg) 97 | except Error as detail: 98 | try: 99 | self.request_msg = request.creditcard_stmt(account, username, password, daysago=90) 100 | return self._send_request(account.institution.ofx_url, self.request_msg) 101 | except Error as detail: 102 | self.request_msg = request.creditcard_stmt(account, username, password, daysago=30) 103 | return self._send_request(account.institution.ofx_url, self.request_msg) 104 | 105 | def get_closing(self, account, username, password): 106 | # FIXME: Make sure this list only exists in one place and isn't duplicated here. 107 | acct_type = account.get_ofx_accttype() 108 | if acct_type == "CREDITCARD": 109 | return self.get_creditcard_closing(account, username, password) 110 | elif acct_type == "CHECKING" or acct_type == "SAVINGS" \ 111 | or acct_type == "MONEYMRKT" or acct_type == "MONEYMARKT" or acct_type == "CREDITLINE": 112 | return self.get_bank_closing(account, username, password) 113 | else: 114 | raise ValueError("Unknown account type '%s'." % acct_type) 115 | 116 | def get_bank_closing(self, account, username, password): 117 | """Sends an OFX request for the given user's bank account 118 | statement, and returns that statement as an OFX document if 119 | the request is successful.""" 120 | acct_type = account.get_ofx_accttype() 121 | request = Request() 122 | self.request_msg = request.bank_closing(account, username, password) 123 | return self._send_request(account.institution.ofx_url, self.request_msg) 124 | 125 | def get_creditcard_closing(self, account, username, password): 126 | """Sends an OFX request for the given user's credit card 127 | statement, and returns that statement if the request is 128 | successful. If the OFX server returns an error, the client 129 | will throw an OfxException indicating the error code and 130 | message.""" 131 | request = Request() 132 | self.request_msg = request.creditcard_closing(account, username, password) 133 | return self._send_request(account.institution.ofx_url, self.request_msg) 134 | 135 | def get_request_message(self): 136 | """Returns the last request message (or None if no request has been 137 | sent) for debugging purposes.""" 138 | return self.request_msg 139 | 140 | def _send_request(self, url, request_body): 141 | """Transmits the message to the server and checks the response 142 | for error status.""" 143 | 144 | request = urllib.request.Request(url, request_body.encode('utf-8'), 145 | { "Content-type": "application/x-ofx", 146 | "Accept": "*/*, application/x-ofx" }) 147 | stream = urllib.request.urlopen(request) 148 | response = stream.read() 149 | stream.close() 150 | 151 | response = Response(response) 152 | response.check_signon_status() 153 | 154 | parsed_ofx = response.as_dict() 155 | 156 | # FIXME: This needs to account for statement closing responses. 157 | 158 | if "BANKMSGSRSV1" in parsed_ofx: 159 | bank_status = \ 160 | parsed_ofx["BANKMSGSRSV1"]["STMTTRNRS"]["STATUS"] 161 | self._check_status(bank_status, "bank statement") 162 | 163 | elif "CREDITCARDMSGSRSV1" in parsed_ofx: 164 | creditcard_status = \ 165 | parsed_ofx["CREDITCARDMSGSRSV1"]["CCSTMTTRNRS"]["STATUS"] 166 | self._check_status(creditcard_status, "credit card statement") 167 | 168 | elif "SIGNUPMSGSRSV1" in parsed_ofx: 169 | acctinfo_status = \ 170 | parsed_ofx["SIGNUPMSGSRSV1"]["ACCTINFOTRNRS"]["STATUS"] 171 | self._check_status(acctinfo_status, "account information") 172 | 173 | return response 174 | 175 | def _check_status(self, status_block, description): 176 | # Convert the PyParsing result object into a dictionary so we can 177 | # provide default values if the status values don't exist in the 178 | # response. 179 | status = status_block.asDict() 180 | 181 | # There is no OFX status code "-1," so I'm using that code as a 182 | # marker for "No status code was returned." 183 | code = status.get("CODE", "-1") 184 | 185 | # Code "0" is "Success"; code "1" is "data is up-to-date." Anything 186 | # else represents an error. 187 | if code is not "0" and code is not "1": 188 | # Try to find information about the error. If the bank didn't 189 | # provide status information, return the value "NONE," which 190 | # should be both clear to a user and a marker of a lack of 191 | # information from the bank. 192 | severity = status.get("SEVERITY", "NONE") 193 | message = status.get("MESSAGE", "NONE") 194 | 195 | # The "description" allows the code to give some indication 196 | # of where the error originated (for instance, the kind of 197 | # account we were trying to download when the error occurred). 198 | error = Error(description, code, severity, message) 199 | raise error 200 | 201 | -------------------------------------------------------------------------------- /bin/ofxfix.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Copyright 2005-2010 Wesabe, Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # 18 | # fixofx.py - canonicalize all recognized upload formats to OFX 2.0 19 | # 20 | 21 | import os 22 | import os.path 23 | import sys 24 | 25 | from fixofx.ofx import Response, FileTyper 26 | from fixofx.ofxtools.ofc_converter import OfcConverter 27 | from fixofx.ofxtools.qif_converter import QifConverter 28 | 29 | 30 | def fixpath(filename): 31 | mypath = os.path.dirname(sys._getframe(1).f_code.co_filename) 32 | return os.path.normpath(os.path.join(mypath, filename)) 33 | 34 | 35 | from optparse import OptionParser 36 | from pyparsing import ParseException 37 | 38 | __doc__ = \ 39 | """Canonicalizes files from several supported data upload formats (currently 40 | OFX 1.02, OFX 1.5, OFX 1.6, OFX 2.0, OFC, and QIF) to OFX 2.0 (which is a 41 | standard XML 1.0 file). Since it is easiest for the database loader to use a 42 | single, XML-based format, and since users might prefer an XML document to OFX 43 | 1.02 or other formats for export, this script essentially removes the need for 44 | any other code to know about all of the variations in data formats. By 45 | default, the converter will read a single file of any supported format from 46 | standard input and write the converted OFX 2.0 file to standard output. A 47 | command line option also allows reading a single file, and other options allow 48 | you to insert data into the output file not available in the source file (for 49 | instance, QIF does not contain the account number, so an option allows you to 50 | specify that for insertion into the OFX output).""" 51 | 52 | # Import Psyco if available, for speed. 53 | try: 54 | import psyco 55 | psyco.full() 56 | 57 | except ImportError: 58 | pass 59 | 60 | 61 | def convert(filecontent, filetype, verbose=False, fid="UNKNOWN", org="UNKNOWN", 62 | bankid="UNKNOWN", accttype="UNKNOWN", acctid="UNKNOWN", 63 | balance="UNKNOWN", curdef=None, lang="ENG", dayfirst=False, 64 | debug=False): 65 | 66 | text = os.linesep.join(s for s in filecontent.splitlines() if s) 67 | 68 | # This finishes a verbosity message started by the caller, where the 69 | # caller explains the source command-line option and this explains the 70 | # source format. 71 | if verbose: 72 | sys.stderr.write("Converting from %s format.\n" % filetype) 73 | 74 | if options.debug and (filetype in ["OFC", "QIF"] or filetype.startswith("OFX")): 75 | sys.stderr.write("Starting work on raw text:\n") 76 | sys.stderr.write(rawtext + "\n\n") 77 | 78 | if filetype.startswith("OFX/2"): 79 | if verbose: sys.stderr.write("No conversion needed; returning unmodified.\n") 80 | 81 | # The file is already OFX 2 -- return it unaltered, ignoring 82 | # any of the parameters passed to this method. 83 | return text 84 | 85 | elif filetype.startswith("OFX"): 86 | if verbose: sys.stderr.write("Converting to OFX/2.0...\n") 87 | 88 | # This will throw a ParseException if it is unable to recognize 89 | # the source format. 90 | response = Response(text, debug=debug) 91 | return response.as_xml(original_format=filetype) 92 | 93 | elif filetype == "OFC": 94 | if verbose: sys.stderr.write("Beginning OFC conversion...\n") 95 | converter = OfcConverter(text, fid=fid, org=org, curdef=curdef, 96 | lang=lang, debug=debug) 97 | 98 | # This will throw a ParseException if it is unable to recognize 99 | # the source format. 100 | if verbose: 101 | sys.stderr.write("Converting to OFX/1.02...\n\n%s\n\n" % 102 | converter.to_ofx102()) 103 | sys.stderr.write("Converting to OFX/2.0...\n") 104 | 105 | return converter.to_xml() 106 | 107 | elif filetype == "QIF": 108 | if verbose: sys.stderr.write("Beginning QIF conversion...\n") 109 | converter = QifConverter(text, fid=fid, org=org, 110 | bankid=bankid, accttype=accttype, 111 | acctid=acctid, balance=balance, 112 | curdef=curdef, lang=lang, dayfirst=dayfirst, 113 | debug=debug) 114 | 115 | # This will throw a ParseException if it is unable to recognize 116 | # the source format. 117 | if verbose: 118 | sys.stderr.write("Converting to OFX/1.02...\n\n%s\n\n" % 119 | converter.to_ofx102()) 120 | sys.stderr.write("Converting to OFX/2.0...\n") 121 | 122 | return converter.to_xml() 123 | 124 | else: 125 | raise TypeError("Unable to convert source format '%s'." % filetype) 126 | 127 | parser = OptionParser(description=__doc__) 128 | parser.add_option("-d", "--debug", action="store_true", dest="debug", 129 | default=False, help="spit out gobs of debugging output during parse") 130 | parser.add_option("-v", "--verbose", action="store_true", dest="verbose", 131 | default=False, help="be more talkative, social, outgoing") 132 | parser.add_option("-t", "--type", action="store_true", dest="type", 133 | default=False, help="print input file type and exit") 134 | parser.add_option("-f", "--file", dest="filename", default=None, 135 | help="source file to convert (writes to STDOUT)") 136 | parser.add_option("--fid", dest="fid", default="UNKNOWN", 137 | help="(OFC/QIF only) FID to use in output") 138 | parser.add_option("--org", dest="org", default="UNKNOWN", 139 | help="(OFC/QIF only) ORG to use in output") 140 | parser.add_option("--curdef", dest="curdef", default=None, 141 | help="(OFC/QIF only) Currency identifier to use in output") 142 | parser.add_option("--lang", dest="lang", default="ENG", 143 | help="(OFC/QIF only) Language identifier to use in output") 144 | parser.add_option("--bankid", dest="bankid", default="UNKNOWN", 145 | help="(QIF only) Routing number to use in output") 146 | parser.add_option("--accttype", dest="accttype", default="UNKNOWN", 147 | help="(QIF only) Account type to use in output") 148 | parser.add_option("--acctid", dest="acctid", default="UNKNOWN", 149 | help="(QIF only) Account number to use in output") 150 | parser.add_option("--balance", dest="balance", default="UNKNOWN", 151 | help="(QIF only) Account balance to use in output") 152 | parser.add_option("--dayfirst", action="store_true", dest="dayfirst", default=False, 153 | help="(QIF only) Parse dates day first (UK format)") 154 | parser.add_option("-s", "--string", dest="string", default=None, 155 | help="string to convert") 156 | (options, args) = parser.parse_args() 157 | 158 | # 159 | # Check the python environment for minimum sanity levels. 160 | # 161 | 162 | if options.verbose and not hasattr(open, 'newlines'): 163 | # Universal newlines are generally needed to deal with various QIF downloads. 164 | sys.stderr.write('Warning: universal newline support NOT available.\n') 165 | 166 | if options.verbose: print("Options: %s" % options) 167 | 168 | # 169 | # Load up the raw text to be converted. 170 | # 171 | 172 | rawtext = None 173 | 174 | if options.filename: 175 | if os.path.isfile(options.filename): 176 | if options.verbose: 177 | sys.stderr.write("Reading from '%s'\n." % options.filename) 178 | 179 | try: 180 | srcfile = open(options.filename, 'rU') 181 | rawtext = srcfile.read() 182 | srcfile.close() 183 | except Exception as detail: 184 | print("Exception during file read:\n%s" % detail) 185 | print("Exiting.") 186 | sys.stderr.write("fixofx failed with error code 1\n") 187 | sys.exit(1) 188 | 189 | else: 190 | print("'%s' does not appear to be a file. Try --help." % options.filename) 191 | sys.stderr.write("fixofx failed with error code 2\n") 192 | sys.exit(2) 193 | 194 | elif options.string: 195 | if options.verbose: 196 | sys.stderr.write("Reading from string\n") 197 | rawtext = options.string.replace('\r','') 198 | 199 | else: 200 | if options.verbose: 201 | sys.stderr.write("Reading from standard input.\n") 202 | 203 | stdin_universal = os.fdopen(os.dup(sys.stdin.fileno()), "rU") 204 | rawtext = stdin_universal.read() 205 | 206 | if rawtext == "" or rawtext is None: 207 | print("No input. Pipe a file to convert to the script,\n" + \ 208 | "or call with -f. Call with --help for more info.") 209 | sys.stderr.write("fixofx failed with error code 3\n") 210 | sys.exit(3) 211 | 212 | # 213 | # Convert the raw text to OFX 2.0. 214 | # 215 | 216 | try: 217 | # Determine the type of file contained in 'text', using a quick guess 218 | # rather than parsing the file to make sure. (Parsing will fail 219 | # below if the guess is wrong on OFX/1 and QIF.) 220 | filetype = FileTyper(rawtext).trust() 221 | 222 | if options.type: 223 | print("Input file type is %s." % filetype) 224 | sys.exit(0) 225 | elif options.debug: 226 | sys.stderr.write("Input file type is %s.\n" % filetype) 227 | 228 | converted = convert(rawtext, filetype, verbose=options.verbose, 229 | fid=options.fid, org=options.org, bankid=options.bankid, 230 | accttype=options.accttype, acctid=options.acctid, 231 | balance=options.balance, curdef=options.curdef, 232 | lang=options.lang, dayfirst=options.dayfirst, 233 | debug=options.debug) 234 | print(converted) 235 | sys.exit(0) 236 | 237 | except ParseException as detail: 238 | print("Parse exception during '%s' conversion:\n%s" % (filetype, detail)) 239 | print("Exiting.") 240 | sys.stderr.write("fixofx failed with error code 4\n") 241 | sys.exit(4) 242 | 243 | except TypeError as detail: 244 | print(detail) 245 | print("Exiting.") 246 | sys.stderr.write("fixofx failed with error code 5\n") 247 | sys.exit(5) 248 | -------------------------------------------------------------------------------- /fixofx/ofxtools/ofc_converter.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | 3 | # Copyright 2005-2010 Wesabe, Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # 18 | # ofx.OfcConverter - translate OFC files into OFX files. 19 | # 20 | import sys 21 | 22 | from fixofx.ofxtools.ofc_parser import OfcParser 23 | from fixofx.ofx import Response 24 | from fixofx.ofx.builder import * 25 | 26 | 27 | class OfcConverter: 28 | def __init__(self, ofc, fid="UNKNOWN", org="UNKNOWN", curdef=None, 29 | lang="ENG", debug=False): 30 | self.ofc = ofc 31 | self.fid = fid 32 | self.org = org 33 | self.curdef = curdef 34 | self.lang = lang 35 | self.debug = debug 36 | 37 | self.bankid = "UNKNOWN" 38 | self.accttype = "UNKNOWN" 39 | self.acctid = "UNKNOWN" 40 | self.balance = "UNKNOWN" 41 | self.start_date = "UNKNOWN" 42 | self.end_date = "UNKNOWN" 43 | 44 | self.parsed_ofc = None 45 | 46 | self.acct_types = { "0" : "CHECKING", 47 | "1" : "SAVINGS", 48 | "2" : "CREDITCARD", 49 | "3" : "MONEYMRKT", 50 | "4" : "CREDITLINE", 51 | "5" : "UNKNOWN", 52 | "6" : "UNKNOWN", 53 | "7" : "UNKNOWN" } 54 | 55 | self.txn_types = { "0" : "CREDIT", 56 | "1" : "DEBIT", 57 | "2" : "INT", 58 | "3" : "DIV", 59 | "4" : "SRVCHG", 60 | "5" : "DEP", 61 | "6" : "ATM", 62 | "7" : "XFER", 63 | "8" : "CHECK", 64 | "9" : "PAYMENT", 65 | "10" : "CASH", 66 | "11" : "DIRECTDEP", 67 | "12" : "OTHER" } 68 | 69 | if self.debug: sys.stderr.write("Parsing document.\n") 70 | 71 | parser = OfcParser(debug=debug) 72 | self.parsed_ofc = parser.parse(self.ofc) 73 | 74 | if self.debug: sys.stderr.write("Extracting document properties.\n") 75 | 76 | if 'TRNRS' in self.parsed_ofc["document"]["OFC"].asDict(): 77 | #TRNRS has almost the same info of ACCTSTMT. Just with another name. Damn you Banks! 78 | self.parsed_ofc["document"]["OFC"]['ACCTSTMT'] = self.parsed_ofc["document"]["OFC"]["TRNRS"] 79 | elif 'TRNRS' in self.parsed_ofc["document"]["OFC"][0].asDict(): 80 | # Crazy hack to make it work 81 | self.parsed_ofc["document"]["OFC"]['ACCTSTMT'] = self.parsed_ofc["document"]["OFC"][0]["TRNRS"] 82 | 83 | if 'ACCTFROM' in self.parsed_ofc["document"]["OFC"]["ACCTSTMT"].asDict(): 84 | # Bank info ignored if not exists 85 | try: 86 | self.bankid = self.parsed_ofc["document"]["OFC"]["ACCTSTMT"]["ACCTFROM"]["BANKID"] 87 | acct_code = self.parsed_ofc["document"]["OFC"]["ACCTSTMT"]["ACCTFROM"]["ACCTTYPE"] 88 | self.accttype = self.acct_types.get(acct_code, "UNKNOWN") 89 | self.acctid = self.parsed_ofc["document"]["OFC"]["ACCTSTMT"]["ACCTFROM"]["ACCTID"] 90 | except KeyError: 91 | self.bankid = self.parsed_ofc["document"]["OFC"]["ACCTSTMT"]["ACCTFROM"]["ACCOUNT"]["BANKID"] 92 | acct_code = self.parsed_ofc["document"]["OFC"]["ACCTSTMT"]["ACCTFROM"]["ACCOUNT"]["ACCTTYPE"] 93 | self.accttype = self.acct_types.get(acct_code, "UNKNOWN") 94 | self.acctid = self.parsed_ofc["document"]["OFC"]["ACCTSTMT"]["ACCTFROM"]["ACCOUNT"]["ACCTID"] 95 | 96 | self.balance = self.parsed_ofc["document"]["OFC"]["ACCTSTMT"]["STMTRS"]["LEDGER"] 97 | self.start_date = self.parsed_ofc["document"]["OFC"]["ACCTSTMT"]["STMTRS"]["DTSTART"] 98 | self.end_date = self.parsed_ofc["document"]["OFC"]["ACCTSTMT"]["STMTRS"]["DTEND"] 99 | 100 | # 101 | # Conversion methods 102 | # 103 | 104 | def to_ofx102(self): 105 | if self.debug: sys.stderr.write("Making OFX/1.02.\n") 106 | return DOCUMENT(self._ofx_header(), 107 | OFX(self._ofx_signon(), 108 | self._ofx_stmt())) 109 | 110 | def to_xml(self): 111 | ofx102 = self.to_ofx102() 112 | 113 | if self.debug: 114 | sys.stderr.write(ofx102 + "\n") 115 | sys.stderr.write("Parsing OFX/1.02.\n") 116 | response = Response(ofx102, debug=self.debug) 117 | 118 | if self.debug: sys.stderr.write("Making OFX/2.0.\n") 119 | 120 | xml = response.as_xml(original_format="OFC") 121 | 122 | return xml 123 | 124 | # FIXME: Move the remaining methods to ofx.Document or ofx.Response. 125 | 126 | def _ofx_header(self): 127 | return HEADER( 128 | OFXHEADER("100"), 129 | DATA("OFXSGML"), 130 | VERSION("102"), 131 | SECURITY("NONE"), 132 | ENCODING("USASCII"), 133 | CHARSET("1252"), 134 | COMPRESSION("NONE"), 135 | OLDFILEUID("NONE"), 136 | NEWFILEUID("NONE")) 137 | 138 | def _ofx_signon(self): 139 | return SIGNONMSGSRSV1( 140 | SONRS( 141 | STATUS( 142 | CODE("0"), 143 | SEVERITY("INFO"), 144 | MESSAGE("SUCCESS")), 145 | DTSERVER(self.end_date), 146 | LANGUAGE(self.lang), 147 | FI( 148 | ORG(self.org), 149 | FID(self.fid)))) 150 | 151 | def _ofx_stmt(self): 152 | # Set default currency here, instead of on init, so that the caller 153 | # can override the currency format found in the QIF file if desired. 154 | # See also _guess_formats(), above. 155 | if self.curdef is None: 156 | curdef = "USD" 157 | else: 158 | curdef = self.curdef 159 | 160 | if self.accttype == "Credit Card": 161 | return CREDITCARDMSGSRSV1( 162 | CCSTMTTRNRS( 163 | TRNUID("0"), 164 | self._ofx_status(), 165 | CCSTMTRS( 166 | CURDEF(curdef), 167 | CCACCTFROM( 168 | ACCTID(self.acctid)), 169 | self._ofx_txns(), 170 | self._ofx_ledgerbal(), 171 | self._ofx_availbal()))) 172 | else: 173 | return BANKMSGSRSV1( 174 | STMTTRNRS( 175 | TRNUID("0"), 176 | self._ofx_status(), 177 | STMTRS( 178 | CURDEF(curdef), 179 | BANKACCTFROM( 180 | BANKID(self.bankid), 181 | ACCTID(self.acctid), 182 | ACCTTYPE(self.accttype)), 183 | self._ofx_txns(), 184 | self._ofx_ledgerbal(), 185 | self._ofx_availbal()))) 186 | 187 | def _ofx_status(self): 188 | return STATUS( 189 | CODE("0"), 190 | SEVERITY("INFO"), 191 | MESSAGE("SUCCESS")) 192 | 193 | def _ofx_ledgerbal(self): 194 | return LEDGERBAL( 195 | BALAMT(self.balance), 196 | DTASOF(self.end_date)) 197 | 198 | def _ofx_availbal(self): 199 | return AVAILBAL( 200 | BALAMT(self.balance), 201 | DTASOF(self.end_date)) 202 | 203 | def _ofx_txns(self): 204 | txns = "" 205 | last_date = None 206 | txn_index = 1 207 | 208 | for item in self.parsed_ofc["document"]["OFC"]["ACCTSTMT"]["STMTRS"]: 209 | if item[0] == "STMTTRN": 210 | txn = item.asDict() 211 | if 'GENTRN' in txn: 212 | txn = txn['GENTRN'].asDict() 213 | 214 | txn_date = txn["DTPOSTED"] 215 | if txn_date != last_date: 216 | last_date = txn_date 217 | txn_index = 1 218 | 219 | txn_amt = txn["TRNAMT"] 220 | txn_type = self.txn_types.get(txn["TRNTYPE"]) 221 | if txn_type is None: 222 | if txn_amt.startswith('-'): 223 | txn["TRNTYPE"] = "DEBIT" 224 | else: 225 | txn["TRNTYPE"] = "CREDIT" 226 | 227 | # Make a synthetic transaction ID using as many 228 | # uniqueness guarantors as possible. 229 | txn["FITID"] = "%s-%s-%s-%s-%s" % (self.org, self.accttype, 230 | txn_date, txn_index, 231 | txn_amt) 232 | txns += self._ofx_txn(txn) 233 | txn_index += 1 234 | 235 | return BANKTRANLIST( 236 | DTSTART(self.start_date), 237 | DTEND(self.end_date), 238 | txns) 239 | 240 | def _ofx_txn(self, txn): 241 | fields = [] 242 | if self._check_field("TRNTYPE", txn): 243 | fields.append(TRNTYPE(txn["TRNTYPE"].strip())) 244 | 245 | if self._check_field("DTPOSTED", txn): 246 | fields.append(DTPOSTED(txn["DTPOSTED"].strip())) 247 | 248 | if self._check_field("TRNAMT", txn): 249 | fields.append(TRNAMT(txn["TRNAMT"].strip())) 250 | 251 | if self._check_field("CHECKNUM", txn): 252 | fields.append(CHECKNUM(txn["CHECKNUM"].strip())) 253 | 254 | if self._check_field("FITID", txn): 255 | fields.append(FITID(txn["FITID"].strip())) 256 | 257 | if self._check_field("NAME", txn): 258 | fields.append(NAME(txn["NAME"].strip())) 259 | 260 | if self._check_field("MEMO", txn): 261 | fields.append(MEMO(txn["MEMO"].strip())) 262 | 263 | return STMTTRN(*fields) 264 | 265 | def _check_field(self, key, txn): 266 | return key in txn and txn[key].strip() != "" 267 | 268 | 269 | -------------------------------------------------------------------------------- /fixofx/ofx/builder.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | # Copyright 2005-2010 Wesabe, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # 17 | # ofx.builder - OFX document generator with focus on clean generation code. 18 | # 19 | 20 | """ 21 | Builder of OFX message documents. This module exposes a large set of 22 | instances that are called as methods to generate an OFX document 23 | component using the name of the instance. Example usage: 24 | 25 | import ofx 26 | 27 | request = MESSAGE( 28 | HEADER( 29 | OFXHEADER("100"), 30 | DATA("OFXSGML"), 31 | VERSION("102"), 32 | SECURITY("NONE"), 33 | ENCODING("USASCII"), 34 | CHARSET("1252"), 35 | COMPRESSION("NONE"), 36 | OLDFILEUID("NONE"), 37 | NEWFILEUID("9B33CA3E-C237-4577-8F00-7AFB0B827B5E")), 38 | OFX( 39 | SIGNONMSGSRQV1(), 40 | # ... other OFX message components here ... 41 | """ 42 | 43 | # FIXME: This supports OFX 1.02. Make something that supports OFX 2.0. 44 | 45 | # REVIEW: This class is pretty hackish, and it's not easy to maintain 46 | # (you have to add new tags in a few different places). However, it works, 47 | # and it has a reasonable test suite, so I'm leaving it alone for now. 48 | # I do need to have a way of generating OFX 2.0, and that will probably 49 | # be the next major addition to the class. 50 | 51 | class Tag: 52 | ofx1 = "OFX/1.0" 53 | ofx2 = "OFX/2.0" 54 | output = ofx1 55 | 56 | def _output_version(cls, version=ofx1): 57 | cls.output = version 58 | 59 | def __init__(self, tag, aggregate=False, header=False, encoding=False, 60 | header_block=False, payload_block=False, message_block=False, document_type=None): 61 | """Builds an OfxTag instance to be called by the name of 62 | the tag it represents. For instance, to make an "APPID" tag, 63 | create the tag object with 'APPID = Tag("APPID")', and 64 | then use the instance as a method: 'APPID("MONEY")'.""" 65 | self.tag = tag 66 | self.aggregate = aggregate 67 | self.header = header 68 | self.encoding = encoding 69 | self.header_block = header_block 70 | self.message_block = message_block 71 | self.payload_block = payload_block 72 | self.document_type = document_type 73 | 74 | def __call__(self, *values, **params): 75 | """Invoked when an OfxTag instance is invoked as a method 76 | call (see constructor documentation for an example). The 77 | instance will return a string using its tag as a marker, 78 | with the arguments to the call used as the value of the tag.""" 79 | if self.document_type is not None: 80 | self._output_version(self.document_type) 81 | 82 | elif self.message_block: 83 | # For consistency, we use an empty join to put together 84 | # parts of an OFX message in a "message block" tag. 85 | return ''.join(values) 86 | 87 | elif self.header_block: 88 | if self.output == "ofx2": 89 | return "\r\n" 90 | else: 91 | # The header block takes all the headers and adds an 92 | # extra newline to signal the end of the block. 93 | return ''.join(values) + "\r\n" 94 | 95 | elif self.payload_block: 96 | # This is really a hack, to make sure that the OFX 97 | # tag generation doesn't end with a newline. Hmm... 98 | return "<" + self.tag + ">" + "\r\n" + ''.join(values) \ 99 | + "" 100 | 101 | elif self.header: 102 | # This is an individual name/value pair in the header. 103 | return self.tag + ":" + ''.join(values) + "\r\n" 104 | 105 | elif self.aggregate: 106 | return "<" + self.tag + ">" + "\r\n" + ''.join(values) \ 107 | + "" + "\r\n" 108 | 109 | else: 110 | if values is None: return "" 111 | values = [str(x) for x in values] 112 | if values == "": return "" 113 | return "<" + self.tag + ">" + ''.join(values) + "\r\n" 114 | 115 | # The following is really dumb and hackish. Is there any way to know the 116 | # name of the variable called when __call__ is invoked? I guess that 117 | # wouldn't help since we have no way of using closures to make a real 118 | # builder. :( 119 | 120 | # This list of variable names is needed to avoid importing the unit test 121 | # suite into any file that uses ofxbuilder. Any new tags added below should 122 | # also be added here, unfortunately. 123 | __all__ = ['ACCTID', 'ACCTINFORQ', 'ACCTINFOTRNRQ', 'ACCTTYPE', 'APPID', 124 | 'APPVER', 'AVAILBAL', 'BALAMT', 'BANKACCTFROM', 'BANKID', 'BANKMSGSRQV1', 125 | 'BANKMSGSRSV1', 'BANKTRANLIST', 'BROKERID', 'CATEGORY', 'CCACCTFROM', 'CCSTMTENDRQ', 126 | 'CCSTMTENDTRNRQ', 'CCSTMTRQ', 'CCSTMTRS', 'CCSTMTTRNRQ', 'CCSTMTTRNRS', 127 | 'CHARSET', 'CHECKNUM', 'CLIENTROUTING', 'CLTCOOKIE', 'CODE', 'COMPRESSION', 128 | 'CREDITCARDMSGSRQV1', 'CREDITCARDMSGSRSV1', 'CURDEF', 'DATA', 'DOCUMENT', 129 | 'DTACCTUP', 'DTASOF', 'DTCLIENT', 'DTEND', 'DTPOSTED', 'DTPROFUP', 'DTSERVER', 130 | 'DTSTART', 'ENCODING', 'FI', 'FID', 'FITID', 'HEADER', 'INCBAL', 'INCLUDE', 131 | 'INCOO', 'INCPOS', 'INCTRAN', 'INVACCTFROM', 'INVSTMTMSGSRQV1', 'INVSTMTRQ', 132 | 'INVSTMTTRNRQ', 'LANGUAGE', 'LEDGERBAL', 'MEMO', 'MESSAGE', 'NAME', 133 | 'NEWFILEUID', 'OFX', 'OFXHEADER', 'OFX1', 'OFX2', 'OLDFILEUID', 'ORG', 134 | 'PROFMSGSRQV1', 'PROFRQ', 'PROFTRNRQ', 'SECURITY', 'SEVERITY', 135 | 'SIGNONMSGSRQV1', 'SIGNONMSGSRSV1', 'SIGNUPMSGSRQV1', 'SONRQ', 'SONRS', 136 | 'STATUS', 'STMTENDRQ', 'STMTENDTRNRQ', 'STMTRQ', 'STMTTRN', 'STMTRS', 137 | 'STMTTRNRQ', 'STMTTRNRS', 'TRNAMT', 'TRNTYPE', 'TRNUID', 'USERID', 'USERPASS', 138 | 'VERSION'] 139 | 140 | # FIXME: Can I add a bunch of fields to the module with a loop? 141 | 142 | ACCTID = Tag("ACCTID") 143 | ACCTINFORQ = Tag("ACCTINFORQ", aggregate=True) 144 | ACCTINFOTRNRQ = Tag("ACCTINFOTRNRQ", aggregate=True) 145 | ACCTTYPE = Tag("ACCTTYPE") 146 | APPID = Tag("APPID") 147 | APPVER = Tag("APPVER") 148 | AVAILBAL = Tag("AVAILBAL", aggregate=True) 149 | BALAMT = Tag("BALAMT") 150 | BANKACCTFROM = Tag("BANKACCTFROM", aggregate=True) 151 | BANKID = Tag("BANKID") 152 | BANKMSGSRQV1 = Tag("BANKMSGSRQV1", aggregate=True) 153 | BANKMSGSRSV1 = Tag("BANKMSGSRSV1", aggregate=True) 154 | BANKTRANLIST = Tag("BANKTRANLIST", aggregate=True) 155 | BROKERID = Tag("BROKERID") 156 | CATEGORY = Tag("CATEGORY") 157 | CCACCTFROM = Tag("CCACCTFROM", aggregate=True) 158 | CCSTMTENDRQ = Tag("CCSTMTENDRQ", aggregate=True) 159 | CCSTMTENDTRNRQ = Tag("CCSTMTENDTRNRQ", aggregate=True) 160 | CCSTMTRQ = Tag("CCSTMTRQ", aggregate=True) 161 | CCSTMTRS = Tag("CCSTMTRS", aggregate=True) 162 | CCSTMTTRNRQ = Tag("CCSTMTTRNRQ", aggregate=True) 163 | CCSTMTTRNRS = Tag("CCSTMTTRNRS", aggregate=True) 164 | CHARSET = Tag("CHARSET", header=True) 165 | CHECKNUM = Tag("CHECKNUM") 166 | CLIENTROUTING = Tag("CLIENTROUTING") 167 | CLTCOOKIE = Tag("CLTCOOKIE") 168 | CODE = Tag("CODE") 169 | COMPRESSION = Tag("COMPRESSION", header=True) 170 | CREDITCARDMSGSRQV1 = Tag("CREDITCARDMSGSRQV1", aggregate=True) 171 | CREDITCARDMSGSRSV1 = Tag("CREDITCARDMSGSRSV1", aggregate=True) 172 | CURDEF = Tag("CURDEF") 173 | DATA = Tag("DATA", header=True) 174 | DOCUMENT = Tag("", message_block=True) 175 | DTACCTUP = Tag("DTACCTUP") 176 | DTASOF = Tag("DTASOF") 177 | DTCLIENT = Tag("DTCLIENT") 178 | DTEND = Tag("DTEND") 179 | DTPOSTED = Tag("DTPOSTED") 180 | DTPROFUP = Tag("DTPROFUP") 181 | DTSERVER = Tag("DTSERVER") 182 | DTSTART = Tag("DTSTART") 183 | ENCODING = Tag("ENCODING", header=True) 184 | FI = Tag("FI", aggregate=True) 185 | FID = Tag("FID") 186 | FITID = Tag("FITID") 187 | HEADER = Tag("", header_block=True) 188 | INCBAL = Tag("INCBAL") 189 | INCLUDE = Tag("INCLUDE") 190 | INCOO = Tag("INCOO") 191 | INCPOS = Tag("INCPOS", aggregate=True) 192 | INCTRAN = Tag("INCTRAN", aggregate=True) 193 | INVACCTFROM = Tag("INVACCTFROM", aggregate=True) 194 | INVSTMTMSGSRQV1 = Tag("INVSTMTMSGSRQV1", aggregate=True) 195 | INVSTMTRQ = Tag("INVSTMTRQ", aggregate=True) 196 | INVSTMTTRNRQ = Tag("INVSTMTTRNRQ", aggregate=True) 197 | LANGUAGE = Tag("LANGUAGE") 198 | LEDGERBAL = Tag("LEDGERBAL", aggregate=True) 199 | MEMO = Tag("MEMO") 200 | MESSAGE = Tag("MESSAGE") 201 | NAME = Tag("NAME") 202 | NEWFILEUID = Tag("NEWFILEUID", header=True) 203 | OFX = Tag("OFX", payload_block=True) 204 | OFX1 = Tag("", document_type=Tag.ofx1) 205 | OFX2 = Tag("", document_type=Tag.ofx2) 206 | OFXHEADER = Tag("OFXHEADER", header=True) 207 | OLDFILEUID = Tag("OLDFILEUID", header=True) 208 | ORG = Tag("ORG") 209 | PROFMSGSRQV1 = Tag("PROFMSGSRQV1", aggregate=True) 210 | PROFRQ = Tag("PROFRQ", aggregate=True) 211 | PROFTRNRQ = Tag("PROFTRNRQ", aggregate=True) 212 | SECURITY = Tag("SECURITY", header=True) 213 | SEVERITY = Tag("SEVERITY") 214 | SIGNONMSGSRQV1 = Tag("SIGNONMSGSRQV1", aggregate=True) 215 | SIGNONMSGSRSV1 = Tag("SIGNONMSGSRSV1", aggregate=True) 216 | SIGNUPMSGSRQV1 = Tag("SIGNUPMSGSRQV1", aggregate=True) 217 | SONRQ = Tag("SONRQ", aggregate=True) 218 | SONRS = Tag("SONRS", aggregate=True) 219 | STATUS = Tag("STATUS", aggregate=True) 220 | STMTENDRQ = Tag("STMTENDRQ", aggregate=True) 221 | STMTENDTRNRQ = Tag("STMTENDTRNRQ", aggregate=True) 222 | STMTRQ = Tag("STMTRQ", aggregate=True) 223 | STMTRS = Tag("STMTRS", aggregate=True) 224 | STMTTRN = Tag("STMTTRN", aggregate=True) 225 | STMTTRNRQ = Tag("STMTTRNRQ", aggregate=True) 226 | STMTTRNRS = Tag("STMTTRNRS", aggregate=True) 227 | TRNAMT = Tag("TRNAMT") 228 | TRNTYPE = Tag("TRNTYPE") 229 | TRNUID = Tag("TRNUID") 230 | USERID = Tag("USERID") 231 | USERPASS = Tag("USERPASS") 232 | VERSION = Tag("VERSION", header=True) 233 | -------------------------------------------------------------------------------- /fixofx/ofxtools/ofx_statement.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | # Copyright 2005-2010 Wesabe, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # 17 | # ofxtools.OfxStatement - build up an OFX statement from source data. 18 | # 19 | 20 | import xml.sax.saxutils as sax 21 | 22 | import dateutil.parser 23 | 24 | from fixofx.ofx.builder import * 25 | 26 | 27 | class OfxStatement: 28 | def __init__(self, fid="UNKNOWN", org="UNKNOWN", bankid="UNKNOWN", 29 | accttype="UNKNOWN", acctid="UNKNOWN", balance="UNKNOWN", 30 | curdef="USD", lang="ENG"): 31 | self.fid = fid 32 | self.org = org 33 | self.bankid = bankid 34 | self.accttype = accttype 35 | self.acctid = acctid 36 | self.balance = balance 37 | self.curdef = curdef 38 | self.lang = lang 39 | 40 | def add_transaction(self, date=None, amount=None, number=None, 41 | type=None, payee=None, memo=None): 42 | txn = OfxTransaction(date, amount, number, type, payee, memo) 43 | 44 | def to_str(self): 45 | pass 46 | 47 | def __str__(self): 48 | pass 49 | 50 | def _ofx_header(self): 51 | return HEADER( 52 | OFXHEADER("100"), 53 | DATA("OFXSGML"), 54 | VERSION("102"), 55 | SECURITY("NONE"), 56 | ENCODING("USASCII"), 57 | CHARSET("1252"), 58 | COMPRESSION("NONE"), 59 | OLDFILEUID("NONE"), 60 | NEWFILEUID("NONE")) 61 | 62 | def _ofx_signon(self): 63 | return SIGNONMSGSRSV1( 64 | SONRS( 65 | STATUS( 66 | CODE("0"), 67 | SEVERITY("INFO"), 68 | MESSAGE("SUCCESS")), 69 | DTSERVER(self.end_date), 70 | LANGUAGE(self.lang), 71 | FI( 72 | ORG(self.org), 73 | FID(self.fid)))) 74 | 75 | def _ofx_stmt(self): 76 | if self.accttype == "CREDITCARD": 77 | return CREDITCARDMSGSRSV1( 78 | CCSTMTTRNRS( 79 | TRNUID("0"), 80 | self._ofx_status(), 81 | CCSTMTRS( 82 | CURDEF(self.curdef), 83 | CCACCTFROM( 84 | ACCTID(self.acctid)), 85 | self._ofx_txns(), 86 | self._ofx_ledgerbal(), 87 | self._ofx_availbal()))) 88 | else: 89 | return BANKMSGSRSV1( 90 | STMTTRNRS( 91 | TRNUID("0"), 92 | self._ofx_status(), 93 | STMTRS( 94 | CURDEF(self.curdef), 95 | BANKACCTFROM( 96 | BANKID(self.bankid), 97 | ACCTID(self.acctid), 98 | ACCTTYPE(self.accttype)), 99 | self._ofx_txns(), 100 | self._ofx_ledgerbal(), 101 | self._ofx_availbal()))) 102 | 103 | def _ofx_status(self): 104 | return STATUS( 105 | CODE("0"), 106 | SEVERITY("INFO"), 107 | MESSAGE("SUCCESS")) 108 | 109 | def _ofx_ledgerbal(self): 110 | return LEDGERBAL( 111 | BALAMT(self.balance), 112 | DTASOF(self.end_date)) 113 | 114 | def _ofx_availbal(self): 115 | return AVAILBAL( 116 | BALAMT(self.balance), 117 | DTASOF(self.end_date)) 118 | 119 | def _ofx_txns(self): 120 | txns = "" 121 | 122 | # OFX transactions appear most recent first, and oldest last, 123 | # so we do a reverse sort of the dates in this statement. 124 | date_list = list(self.txns_by_date.keys()) 125 | date_list.sort() 126 | date_list.reverse() 127 | for date in date_list: 128 | txn_list = self.txns_by_date[date] 129 | txn_index = len(txn_list) 130 | for txn in txn_list: 131 | txn_date = txn.get("Date", "UNKNOWN") 132 | txn_amt = txn.get("Amount", "00.00") 133 | 134 | # Make a synthetic transaction ID using as many 135 | # uniqueness guarantors as possible. 136 | txn["ID"] = "%s-%s-%s-%s-%s" % (self.org, self.accttype, 137 | txn_date, txn_index, 138 | txn_amt) 139 | txns += self._ofx_txn(txn) 140 | txn_index -= 1 141 | 142 | # FIXME: This should respect the type of statement being generated. 143 | return BANKTRANLIST( 144 | DTSTART(self.start_date), 145 | DTEND(self.end_date), 146 | txns) 147 | 148 | def _ofx_txn(self, txn): 149 | fields = [] 150 | if self._check_field("Type", txn): 151 | fields.append(TRNTYPE(txn["Type"].strip())) 152 | 153 | if self._check_field("Date", txn): 154 | fields.append(DTPOSTED(txn["Date"].strip())) 155 | 156 | if self._check_field("Amount", txn): 157 | fields.append(TRNAMT(txn["Amount"].strip())) 158 | 159 | if self._check_field("Number", txn): 160 | fields.append(CHECKNUM(txn["Number"].strip())) 161 | 162 | if self._check_field("ID", txn): 163 | fields.append(FITID(txn["ID"].strip())) 164 | 165 | if self._check_field("Payee", txn): 166 | fields.append(NAME(sax.escape(sax.unescape(txn["Payee"].strip())))) 167 | 168 | if self._check_field("Memo", txn): 169 | fields.append(MEMO(sax.escape(sax.unescape(txn["Memo"].strip())))) 170 | 171 | return STMTTRN(*fields) 172 | 173 | def _check_field(self, key, txn): 174 | return key in txn and txn[key].strip() != "" 175 | 176 | # 177 | # ofxtools.OfxTransaction - clean and format transaction information. 178 | # 179 | # Copyright Wesabe, Inc. (c) 2005-2007. All rights reserved. 180 | # 181 | 182 | class OfxTransaction: 183 | def __init__(self, date=None, amount=None, number=None, 184 | type=None, payee=None, memo=None): 185 | self.raw_date = date 186 | self.date = None 187 | self.amount = amount 188 | self.number = number 189 | self.type = type 190 | self.payee = payee 191 | self.memo = memo 192 | self.dayfirst = False 193 | 194 | # This is a list of possible transaction types embedded in the 195 | # QIF Payee or Memo field (depending on bank and, it seems, 196 | # other factors). The keys are used to match possible fields 197 | # that we can identify. The values are used as substitutions, 198 | # since banks will use their own vernacular (like "DBT" 199 | # instead of "DEBIT") for some transaction types. All of the 200 | # types in the values column (except "ACH", which is given 201 | # special treatment) are OFX-2.0 standard transaction types; 202 | # the keys are not all standard. To add a new translation, 203 | # find the QIF name for the transaction type, and add it to 204 | # the keys column, then add the appropriate value from the 205 | # OFX-2.0 spec (see page 180 of doc/ofx/ofx-2.0/ofx20.pdf). 206 | # The substitution will be made if either the payee or memo 207 | # field begins with one of the keys followed by a "/", OR if 208 | # the payee or memo field exactly matches a key. 209 | self.txn_types = { "ACH" : "ACH", 210 | "CHECK CARD" : "POS", 211 | "CREDIT" : "CREDIT", 212 | "DBT" : "DEBIT", 213 | "DEBIT" : "DEBIT", 214 | "INT" : "INT", 215 | "DIV" : "DIV", 216 | "FEE" : "FEE", 217 | "SRVCHG" : "SRVCHG", 218 | "DEP" : "DEP", 219 | "DEPOSIT" : "DEP", 220 | "ATM" : "ATM", 221 | "POS" : "POS", 222 | "XFER" : "XFER", 223 | "CHECK" : "CHECK", 224 | "PAYMENT" : "PAYMENT", 225 | "CASH" : "CASH", 226 | "DIRECTDEP" : "DIRECTDEP", 227 | "DIRECTDEBIT" : "DIRECTDEBIT", 228 | "REPEATPMT" : "REPEATPMT", 229 | "OTHER" : "OTHER" } 230 | 231 | def guess_date_format(self): 232 | pass 233 | 234 | def set_date_format(self, dayfirst=False): 235 | self.dayfirst = dayfirst 236 | 237 | def parse_date(self): 238 | # Try as best we can to parse the date into a datetime object. Note: 239 | # this assumes that we never see a timestamp, just the date, in any 240 | # QIF date. 241 | txn_date = self.date 242 | if self.date != "UNKNOWN": 243 | try: 244 | return dateutil.parser.parse(self.date, dayfirst=self.dayfirst) 245 | 246 | except ValueError: 247 | # dateutil.parser doesn't recognize dates of the 248 | # format "MMDDYYYY", though it does recognize 249 | # "MM/DD/YYYY". So, if parsing has failed above, 250 | # try shoving in some slashes and see if that 251 | # parses. 252 | try: 253 | if len(self.date) == 8: 254 | # The int() cast will only succeed if all 8 255 | # characters of txn_date are numbers. If 256 | # it fails, it will throw an exception we 257 | # can catch below. 258 | date_int = int(self.date) 259 | # No exception? Great, keep parsing the 260 | # string (dateutil wants a string 261 | # argument). 262 | slashified = "%s/%s/%s" % (txn_date[0:2], 263 | txn_date[2:4], 264 | txn_date[4:]) 265 | return dateutil.parser.parse(slashified, 266 | dayfirst=self.dayfirst) 267 | except: 268 | pass 269 | 270 | # If we've made it this far, our guesses have failed. 271 | raise ValueError("Unrecognized date format: '%s'." % txn_date) 272 | else: 273 | return "UNKNOWN" 274 | 275 | def clean_date(self): 276 | pass 277 | 278 | def clean_amount(self): 279 | pass 280 | 281 | def clean_number(self): 282 | pass 283 | 284 | def clean_type(self): 285 | pass 286 | 287 | def clean_payee(self): 288 | pass 289 | 290 | def to_str(self): 291 | pass 292 | 293 | def __str__(self): 294 | pass 295 | 296 | --------------------------------------------------------------------------------