diff --git a/.travis.yml b/.travis.yml index 282ae7d..8cad73c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,10 @@ language: python python: - '2.7' -- '3.4' - '3.5' - '3.6' +- '3.7' +- '3.8' install: - if [[ $TRAVIS_PYTHON_VERSION == 2* ]]; then pip install BeautifulSoup six nose coverage python-coveralls; fi @@ -15,6 +16,7 @@ after_success: - coveralls deploy: provider: pypi + edge: true user: jseutter password: secure: buE5iS5WhggpFcqR7iIEfcnDNHGeZ4zcYlgy3p9mJKEP8s7NMVeYJc+0FnnNs2fOEVR1QUX/URFtAZegtW9Bi/hVSc2bECZxM75uH342vqtea2rNJ7wQLSugUO+w9Q7HvC2KqeVl3s5Qa4Y3+mwv3Ej4tPI/WfASaNZG3XkwX4c= @@ -22,3 +24,4 @@ deploy: tags: true distributions: sdist bdist_wheel repo: jseutter/ofxparse + skip_existing: true diff --git a/README.rst b/README.rst index de44e9a..cf42fdb 100644 --- a/README.rst +++ b/README.rst @@ -32,7 +32,7 @@ Here's a sample program # Account - account = ofx.occount + account = ofx.account account.account_id # The account number account.number # The account number (deprecated -- returns account_id) account.routing_number # The bank routing number @@ -73,6 +73,7 @@ Here's a sample program transaction.payee transaction.type transaction.date + transaction.user_date transaction.amount transaction.id transaction.memo diff --git a/ofxparse/__init__.py b/ofxparse/__init__.py index b12d53b..a2983cf 100644 --- a/ofxparse/__init__.py +++ b/ofxparse/__init__.py @@ -4,7 +4,7 @@ Statement, Transaction) from .ofxprinter import OfxPrinter -__version__ = '0.18' +__version__ = '0.21' __all__ = [ 'OfxParser', 'OfxParserException', diff --git a/ofxparse/ofxparse.py b/ofxparse/ofxparse.py index 2621286..fa3a791 100644 --- a/ofxparse/ofxparse.py +++ b/ofxparse/ofxparse.py @@ -13,6 +13,11 @@ except ImportError: from io import StringIO +try: + from collections.abc import Iterable +except ImportError: + from collections import Iterable + import six from . import mcc @@ -37,7 +42,7 @@ def try_decode(string, encoding): def is_iterable(candidate): if sys.version_info < (2, 6): return hasattr(candidate, 'next') - return isinstance(candidate, collections.Iterable) + return isinstance(candidate, Iterable) @contextlib.contextmanager @@ -117,7 +122,10 @@ def handle_encoding(self): if enc_type == "USASCII": cp = ascii_headers.get("CHARSET", "1252") - encoding = "cp%s" % (cp, ) + if cp == "8859-1": + encoding = "iso-8859-1" + else: + encoding = "cp%s" % (cp, ) elif enc_type in ("UNICODE", "UTF-8"): encoding = "utf-8" @@ -304,6 +312,7 @@ def __init__(self): self.payee = '' self.type = '' self.date = None + self.user_date = None self.amount = None self.id = '' self.memo = '' @@ -411,6 +420,9 @@ def parse(cls, file_handle, fail_fast=True, custom_date_format=None): ) ofx_obj.status['severity'] = \ stmttrnrs_status.find('severity').contents[0].strip() + message = stmttrnrs_status.find('message') + ofx_obj.status['message'] = \ + message.contents[0].strip() if message else None ccstmttrnrs = ofx.find('ccstmttrnrs') if ccstmttrnrs: @@ -426,6 +438,9 @@ def parse(cls, file_handle, fail_fast=True, custom_date_format=None): ) ofx_obj.status['severity'] = \ ccstmttrnrs_status.find('severity').contents[0].strip() + message = ccstmttrnrs_status.find('message') + ofx_obj.status['message'] = \ + message.contents[0].strip() if message else None stmtrs_ofx = ofx.findAll('stmtrs') if stmtrs_ofx: @@ -1019,6 +1034,19 @@ def parseTransaction(cls, txn_ofx): raise OfxParserException( six.u("Missing Transaction Date (a required field)")) + user_date_tag = txn_ofx.find('dtuser') + if hasattr(user_date_tag, "contents"): + try: + transaction.user_date = cls.parseOfxDateTime( + user_date_tag.contents[0].strip()) + except IndexError: + raise OfxParserException("Invalid Transaction User Date") + except ValueError: + ve = sys.exc_info()[1] + raise OfxParserException(str(ve)) + except TypeError: + pass + id_tag = txn_ofx.find('fitid') if hasattr(id_tag, "contents"): try: @@ -1051,13 +1079,14 @@ def parseTransaction(cls, txn_ofx): if cls.fail_fast: raise - checknum_tag = txn_ofx.find('checknum') - if hasattr(checknum_tag, 'contents'): - try: - transaction.checknum = checknum_tag.contents[0].strip() - except IndexError: - raise OfxParserException(six.u("Empty Check (or other reference) \ - number")) + for check_field in ('checknum', 'chknum'): + checknum_tag = txn_ofx.find(check_field) + if hasattr(checknum_tag, 'contents'): + try: + transaction.checknum = checknum_tag.contents[0].strip() + except IndexError: + raise OfxParserException(six.u("Empty Check (or other reference) \ + number")) return transaction @@ -1073,4 +1102,8 @@ def toDecimal(cls, tag): # Handle 10000,50 formatted numbers if '.' not in d and ',' in d: d = d.replace(',', '.') + # Handle 1 025,53 formatted numbers + d = d.replace(' ', '') + # Handle +1058,53 formatted numbers + d = d.replace('+', '') return decimal.Decimal(d) diff --git a/requirements.txt b/requirements.txt index ae10411..e076f7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ python-coveralls +beautifulsoup4 diff --git a/tests/fixtures/error_message.ofx b/tests/fixtures/error_message.ofx new file mode 100644 index 0000000..6996ab1 --- /dev/null +++ b/tests/fixtures/error_message.ofx @@ -0,0 +1,29 @@ + + + + + + + 0 + INFO + SUCCESS + + 20180521052952.749[-7:PDT] + ENG + + svb.com + 944 + + + + + + ae91f50f-f16d-4bc1-b88f-2a7fa04b6de1 + + 2000 + ERROR + General Server Error + + + + diff --git a/tests/test_parse.py b/tests/test_parse.py index 46ad680..cb5753c 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -415,6 +415,7 @@ def testThatParseTransactionReturnsATransaction(self): input = ''' POS + 20090131 20090401122017.000[-5:EST] -6.60 0000123456782009040100001 @@ -427,6 +428,7 @@ def testThatParseTransactionReturnsATransaction(self): self.assertEqual('pos', transaction.type) self.assertEqual(datetime( 2009, 4, 1, 12, 20, 17) - timedelta(hours=-5), transaction.date) + self.assertEqual(datetime(2009, 1, 31, 0, 0), transaction.user_date) self.assertEqual(Decimal('-6.60'), transaction.amount) self.assertEqual('0000123456782009040100001', transaction.id) self.assertEqual("MCDONALD'S #112", transaction.payee) @@ -447,6 +449,20 @@ def testThatParseTransactionWithFieldCheckNum(self): transaction = OfxParser.parseTransaction(txn.find('stmttrn')) self.assertEqual('700', transaction.checknum) + def testThatParseTransactionWithFieldChkNum(self): + input = ''' + + CHECK + 20231121 + -113.71 + 0000489 + 1932 + +''' + txn = soup_maker(input) + transaction = OfxParser.parseTransaction(txn.find('stmttrn')) + self.assertEqual('1932', transaction.checknum) + def testThatParseTransactionWithCommaAsDecimalPoint(self): input = ''' @@ -493,6 +509,38 @@ def testThatParseTransactionWithDotAsDecimalPointAndCommaAsSeparator(self): transaction = OfxParser.parseTransaction(txn.find('stmttrn')) self.assertEqual(Decimal('-1006.60'), transaction.amount) + def testThatParseTransactionWithLeadingPlusSign(self): + " Parse numbers with a leading '+' sign. " + input = ''' + + POS + 20090401122017.000[-5:EST] + +1,006.60 + 0000123456782009040100001 + MCDONALD'S #112 + POS MERCHANDISE;MCDONALD'S #112 + +''' + txn = soup_maker(input) + transaction = OfxParser.parseTransaction(txn.find('stmttrn')) + self.assertEqual(Decimal('1006.60'), transaction.amount) + + def testThatParseTransactionWithSpaces(self): + " Parse numbers with a space separating the thousands. " + input = ''' + + POS + 20090401122017.000[-5:EST] + +1 006,60 + 0000123456782009040100001 + MCDONALD'S #112 + POS MERCHANDISE;MCDONALD'S #112 + +''' + txn = soup_maker(input) + transaction = OfxParser.parseTransaction(txn.find('stmttrn')) + self.assertEqual(Decimal('1006.60'), transaction.amount) + def testThatParseTransactionWithNullAmountIgnored(self): """A null transaction value is converted to 0. @@ -852,7 +900,7 @@ def testPositions(self): account = ofx.accounts[0] statement = account.statement positions = statement.positions - self.assertEquals(len(positions), 2) + self.assertEqual(len(positions), 2) expected_positions = [ { @@ -981,6 +1029,14 @@ def testEmptyBalance(self): with open_file('fail_nice/empty_balance.ofx') as f: self.assertRaises(OfxParserException, OfxParser.parse, f) + def testErrorInTransactionList(self): + """There is an error in the transaction list.""" + with open_file('error_message.ofx') as f: + ofx = OfxParser.parse(f, False) + self.assertEqual(ofx.status['code'], 2000) + self.assertEqual(ofx.status['severity'], 'ERROR') + self.assertEqual(ofx.status['message'], 'General Server Error') + class TestParseSonrs(TestCase):