diff --git a/.gitignore b/.gitignore index c3b372d..74e8afb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ .idea/ example*.log examples/.ipynb_checkpoints/* +.*.sw* # C extensions *.so diff --git a/README.md b/README.md index e59541d..ccab83f 100644 --- a/README.md +++ b/README.md @@ -80,21 +80,23 @@ usage: csv2ofx [options] description: csv2ofx converts a csv file to ofx and qif positional arguments: - source the source csv file (defaults to stdin) - dest the output file (defaults to stdout) + source the source csv file (default: stdin) + dest the output file (default: stdout) optional arguments: -h, --help show this help message and exit -a TYPE, --account TYPE default account type 'CHECKING' for OFX and 'Bank' for QIF. - -e DATE, --end DATE end date + -e DATE, --end DATE end date (default: today) + -B BALANCE, --ending-balance BALANCE + ending balance (default: None) -l LANGUAGE, --language LANGUAGE - the language + the language (default: ENG) -s DATE, --start DATE the start date -y, --dayfirst interpret the first value in ambiguous dates (e.g. 01/05/09) as the day - -m MAPPING, --mapping MAPPING - the account mapping + -m MAPPING_NAME, --mapping MAPPING_NAME + the account mapping (default: default) -x FILE_PATH, --custom FILE_PATH path to a custom mapping file -c FIELD_NAME, --collapse FIELD_NAME @@ -102,16 +104,17 @@ optional arguments: -C ROWS, --chunksize ROWS number of rows to process at a time (default: 2 ** 14) -r ROWS, --first-row ROWS - number of initial rows to skip (default: 0) - -r ROWS, --last-row ROWS - the final rows to process, negative values count from the end (default: inf) + the first row to process (zero based) + -R ROWS, --last-row ROWS + the last row to process (zero based, negative values count from the end) -O COLS, --first-col COLS - number of initial cols to skip (default: 0) + the first column to process (zero based) -L, --list-mappings list the available mappings -V, --version show version and exit -q, --qif enables 'QIF' output instead of 'OFX' + -M, --ms-money enables MS Money compatible 'OFX' output -o, --overwrite overwrite destination file if it exists - -D SERVER_DATE, --server-date SERVER_DATE + -D DATE, --server-date DATE OFX server date (default: source file mtime) -E ENCODING, --encoding ENCODING File encoding (default: utf-8) diff --git a/csv2ofx/__init__.py b/csv2ofx/__init__.py index af135ee..33e1e1e 100644 --- a/csv2ofx/__init__.py +++ b/csv2ofx/__init__.py @@ -46,6 +46,11 @@ md5 = lambda content: hashlib.md5(content.encode("utf-8")).hexdigest() +class BalanceError(Exception): + """Raised if no ending balance when MS Money compatible output requested""" + pass + + class Content(object): # pylint: disable=too-many-instance-attributes """A transaction holding object""" @@ -76,6 +81,7 @@ def __init__(self, mapping=None, **kwargs): self.parse_fmt = kwargs.get("parse_fmt") self.dayfirst = kwargs.get("dayfirst") self.filter = kwargs.get("filter") + self.ms_money = kwargs.get("ms_money") self.split_account = None self.inv_split_account = None self.id = None @@ -226,7 +232,7 @@ def transaction_data(self, trxn): # pylint: disable=too-many-locals ... 'split_account': 'Checking', 'type': 'DEBIT', ... 'category': '', 'amount': Decimal('-1000.00'), ... 'memo': 'description notes', 'inv_split_account': None, - ... 'x_action': ''} + ... 'x_action': '', 'balance': None} True """ account = self.get("account", trxn) @@ -251,6 +257,9 @@ def transaction_data(self, trxn): # pylint: disable=too-many-locals symbol = self.get("symbol", trxn, "") price = Decimal(self.get("price", trxn, 0)) invest = shares or (symbol and symbol != "N/A") or "invest" in category + balance = self.get("balance", trxn) + if balance is not None: + balance = utils.convert_amount(balance) if invest: amount = abs(amount) @@ -288,6 +297,7 @@ def transaction_data(self, trxn): # pylint: disable=too-many-locals "id": self.get("id", trxn, check_num) or md5(details), "check_num": check_num, "type": _type, + "balance": balance, } def gen_trxns(self, groups, collapse=False): diff --git a/csv2ofx/main.py b/csv2ofx/main.py index ee248de..7962089 100755 --- a/csv2ofx/main.py +++ b/csv2ofx/main.py @@ -41,7 +41,7 @@ from dateutil.parser import parse from meza.io import read_csv, IterStringIO, write -from . import utils +from . import BalanceError, utils from .ofx import OFX from .qif import QIF @@ -77,6 +77,13 @@ help="end date (default: today)", default=str(dt.now()), ) +parser.add_argument( + "-B", + "--ending-balance", + metavar="BALANCE", + type=float, + help="ending balance (default: None)", +) parser.add_argument( "-l", "--language", help="the language (default: ENG)", default="ENG" ) @@ -157,6 +164,13 @@ action="store_true", default=False, ) +parser.add_argument( + "-M", + "--ms-money", + help="enables MS Money compatible 'OFX' output", + action="store_true", + default=False, +) parser.add_argument( "-o", "--overwrite", @@ -216,6 +230,7 @@ def run(): # noqa: C901 "def_type": args.account_type or "Bank" if args.qif else "CHECKING", "start": parse(args.start, dayfirst=args.dayfirst) if args.start else None, "end": parse(args.end, dayfirst=args.dayfirst) if args.end else None, + "ms_money": args.ms_money, } cont = QIF(mapping, **okwargs) if args.qif else OFX(mapping, **okwargs) @@ -248,7 +263,7 @@ def run(): # noqa: C901 server_date = dt.fromtimestamp(mtime) header = cont.header(date=server_date, language=args.language) - footer = cont.footer(date=server_date) + footer = cont.footer(date=server_date, balance=args.ending_balance) filtered = filter(None, [header, body, footer]) content = it.chain.from_iterable(filtered) kwargs = { @@ -277,6 +292,8 @@ def run(): # noqa: C901 # csv2ofx called with no arguments or broken mapping msg = "Possible mapping problem: %s. " % str(err) parser.print_help() + except BalanceError as err: + msg = "%s. Try again with `--ending-balance` option." % err except Exception: # pylint: disable=broad-except msg = 1 traceback.print_exc() diff --git a/csv2ofx/mappings/schwabchecking.py b/csv2ofx/mappings/schwabchecking.py new file mode 100644 index 0000000..495dd1b --- /dev/null +++ b/csv2ofx/mappings/schwabchecking.py @@ -0,0 +1,29 @@ +from __future__ import ( + absolute_import, division, print_function, unicode_literals) + +from operator import itemgetter + +# Schwab Bank includes three lines of text in their CSV which otherwise break +# date parsing. Filtering by lines with no "Type" (and faking out the date for +# those lines) avoids the problem. + +# Note: Ideally, we could get account_id from the first line of the CSV. + +mapping = { + 'first_row': 1, + 'has_header': True, + 'filter': lambda tr: tr.get('Type'), + 'is_split': False, + 'bank': 'Charles Schwab Bank, N.A.', + 'bank_id': '121202211', + 'account_id': '12345', # Change to your actual account number if desired + 'currency': 'USD', + 'account': 'Charles Schwab Checking', + 'date': lambda tr: tr.get('Date') if tr.get('Type') else "1-1-1970", + 'check_num': itemgetter('Check #'), + 'payee': itemgetter('Description'), + 'desc': itemgetter('Description'), + 'type': lambda tr: 'debit' if tr.get('Withdrawal (-)') != '' else 'credit', + 'amount': lambda tr: tr.get('Deposit (+)') or tr.get('Withdrawal (-)'), + 'balance': itemgetter('RunningBalance'), +} diff --git a/csv2ofx/ofx.py b/csv2ofx/ofx.py index 102bddd..e6095e3 100644 --- a/csv2ofx/ofx.py +++ b/csv2ofx/ofx.py @@ -23,7 +23,7 @@ from meza.fntools import chunk, xmlize from meza.process import group -from . import Content, utils +from . import BalanceError, Content, utils class OFX(Content): @@ -50,6 +50,14 @@ def __init__(self, mapping=None, **kwargs): self.resp_type = "INTRATRNRS" if self.split_account else "STMTTRNRS" self.def_type = kwargs.get("def_type") self.prev_group = None + self.first_trxn = None + self.last_trxn = None + self.latest_trxn = None + self.latest_date_count = 0 + self.dates_ascending = 0 + self.dates_descending = 0 + self.balances_ascending = 0 + self.balances_descending = 0 self.account_types = { "CHECKING": ("checking", "income", "receivable", "payable"), "SAVINGS": ("savings",), @@ -75,6 +83,19 @@ def header(self, **kwargs): 0INFO\ ' >>> result = OFX().header(**kwargs) + >>> result = list(result)[0] + >>> header == result.replace('\\n', '').replace('\\t', '') + True + >>> msmoneyargs = { 'ms_money': True } + >>> header = 'OFXHEADER:100DATA:OFXSGMLVERSION:102SECURITY:NONE\ +ENCODING:USASCIICHARSET:1252COMPRESSION:NONEOLDFILEUID:NONENEWFILEUID:NONE\ +\ +0INFO\ +20120115000000ENG\ +10INFO\ +' + >>> result = OFX(msmoneyargs).header(**kwargs) + >>> result = list(result)[0] >>> header == result.replace('\\n', '').replace('\\t', '') True """ @@ -83,8 +104,20 @@ def header(self, **kwargs): # yyyymmddhhmmss time_stamp = kwargs.get("date", dt.now()).strftime("%Y%m%d%H%M%S") - content = "DATA:OFXSGML\n" - content += "ENCODING:UTF-8\n" + content = "" + if self.ms_money: + content += "OFXHEADER:100\n" + content += "DATA:OFXSGML\n" + if self.ms_money: + content += "VERSION:102\n" + content += "SECURITY:NONE\n" + content += "ENCODING:USASCII\n" + content += "CHARSET:1252\n" + content += "COMPRESSION:NONE\n" + content += "OLDFILEUID:NONE\n" + content += "NEWFILEUID:NONE\n" + else: + content += "ENCODING:UTF-8\n" content += "\n" content += "\t\n" content += "\t\t\n" @@ -98,12 +131,15 @@ def header(self, **kwargs): content += "\t\n" content += "\t\n" content += "\t\t<%s>\n" % self.resp_type - content += "\t\t\t\n" + if self.ms_money: + content += "\t\t\t1\n" + else: + content += "\t\t\t\n" content += "\t\t\t\n" content += "\t\t\t\t0\n" content += "\t\t\t\tINFO\n" content += "\t\t\t\n" - return content + yield content def transaction_data(self, trxn): """gets OFX transaction data @@ -137,7 +173,8 @@ def transaction_data(self, trxn): ... 'date': dt(2010, 6, 12, 0, 0), 'category': '', ... 'bank_id': 'e268443e43d93dab7ebef303bbe9642f', ... 'price': Decimal('0'), 'symbol': '', 'check_num': None, - ... 'inv_split_account': None, 'x_action': '', 'type': 'DEBIT'} + ... 'inv_split_account': None, 'x_action': '', 'type': 'DEBIT', + ... 'balance': None} True """ data = super(OFX, self).transaction_data(trxn) @@ -147,11 +184,27 @@ def transaction_data(self, trxn): memo = data.get("memo") _class = data.get("class") memo = "%s %s" % (memo, _class) if memo and _class else memo or _class + payee = data.get("payee") + date = data.get("date") + if self.ms_money: + payee = payee[:32] if len(payee) > 32 else payee + if date.strftime("%H%M%S") == "000000": + # Per MS Money OFX Troubleshooting guide: + # "Microsoft recommends that servers either send server time in + # full datetime format or send dates with a datetime format that + # equates to Noon GMT, such as CCYYMMDD120000. With this format, + # Money displays the expected date for almost any time in the + # world. In the example above, a 20000505120000 would + # always display as 5/5/00 anywhere the world except for the + # center of the Pacific Ocean." + date = date.replace(hour=12) new_data = { "account_type": utils.get_account_type(data["account"], *args), "split_account_type": sa_type, "memo": memo, + "payee": payee, + "date": date, } data.update(new_data) @@ -184,12 +237,28 @@ def account_start(self, **kwargs): >>> start == result.replace('\\n', '').replace('\\t', '') True """ - kwargs.update( - { - "start_date": self.start.strftime("%Y%m%d"), - "end_date": self.end.strftime("%Y%m%d"), - } - ) + if self.ms_money: + # Per MS Money OFX Troubleshooting guide: + # "Microsoft recommends that servers either send server time in + # full datetime format or send dates with a datetime format that + # equates to Noon GMT, such as CCYYMMDD120000. With this format, + # Money displays the expected date for almost any time in the + # world. In the example above, a 20000505120000 would + # always display as 5/5/00 anywhere the world except for the + # center of the Pacific Ocean." + kwargs.update( + { + "start_date": self.start.strftime("%Y%m%d120000"), + "end_date": self.end.strftime("%Y%m%d120000"), + } + ) + else: + kwargs.update( + { + "start_date": self.start.strftime("%Y%m%d"), + "end_date": self.end.strftime("%Y%m%d"), + } + ) content = "\t\t\t\n" content += "\t\t\t\t%(currency)s\n" % kwargs @@ -239,7 +308,8 @@ def transaction(self, **kwargs): content += "\t\t\t\t\t\t%(amount)0.2f\n" % kwargs content += "\t\t\t\t\t\t%(id)s\n" % kwargs - if kwargs.get("check_num") is not None: + if (self.ms_money and kwargs.get("check_num")) or \ + (not self.ms_money and kwargs.get("check_num") is not None): extra = "\t\t\t\t\t\t%(check_num)s\n" content += extra % kwargs @@ -273,11 +343,50 @@ def account_end(self, **kwargs): time_stamp = kwargs["date"].strftime("%Y%m%d%H%M%S") # yyyymmddhhmmss content = "\t\t\t\t\n" - if kwargs.get("balance") is not None: + # Use the following ranked rules to guess at transaction order: + # 1. If the transaction with the latest date is the only transcation + # with that date, use the balance on that transaction and don't + # worry about transaction order. + # 2. If dates are both ascending and descending, don't get ending + # balance from transactions. + # 3. If dates are ascending, use the last transaction. + # 4. If dates are descending, use the first transaction. + # 5. If more balances are consistent with ascending order, use the + # last transaction. + # 6. If more balances are consistent with descending order, use the + # first transaction. + # 7. Don't get ending balance from transactions. + if self.latest_date_count == 1: # (1) + endbaltrxn = self.latest_trxn + elif self.dates_ascending and self.dates_descending: # (2) + reason = "transactions have both ascending and descending dates" + endbaltrxn = None + elif self.dates_ascending: # (3) + endbaltrxn = self.last_trxn + elif self.dates_descending: # (4) + endbaltrxn = self.first_trxn + elif self.balances_ascending > self.balances_descending: # (5) + endbaltrxn = self.last_trxn + elif self.balances_descending > self.balances_ascending: # (6) + endbaltrxn = self.first_trxn + else: # (7) + reason = "not enough information to determine ending balance" + endbaltrxn = None + + balamt = kwargs.get("balance") + if balamt is None and endbaltrxn is not None: + # No balance specified in kwargs. Get from transaction instead. + balamt = endbaltrxn.get('balance') + time_stamp = endbaltrxn['date'].strftime("%Y%m%d%H%M%S") + + if balamt is not None: content += "\t\t\t\t\n" - content += "\t\t\t\t\t%(balance)0.2f\n" % kwargs + content += "\t\t\t\t\t%0.2f\n" % balamt content += "\t\t\t\t\t%s\n" % time_stamp content += "\t\t\t\t\n" + elif self.ms_money: + # MS Money import fails if is missing + raise BalanceError("Ending balance not specified and %s" % reason) content += "\t\t\t\n" return content @@ -425,6 +534,7 @@ def footer(self, **kwargs): Examples: >>> ft = '' >>> result = OFX().footer(date=dt(2012, 1, 15)) + >>> result = list(result)[0] >>> ft == result.replace('\\n', '').replace('\\t', '') True """ @@ -438,7 +548,7 @@ def footer(self, **kwargs): content = "" content += "\t\t\n\t\n\n" % self.resp_type - return content + yield content def gen_body(self, data): # noqa: C901 """Generate the OFX body""" @@ -468,13 +578,167 @@ def gen_body(self, data): # noqa: C901 elif self.is_split: yield self.split_content(**trxn_data) elif datum["is_main"]: + self.calc_balances(trxn_data) yield self.account_start(**trxn_data) yield self.transaction(**trxn_data) else: + self.calc_balances(trxn_data) yield self.transaction(**trxn_data) self.prev_group = grp + def calc_balances(self, trxn): + """Analyzes pairs of transactions to help determine the correct ending + balance to use in block. + + Args: + trxn (dict): the transaction + """ + if trxn.get("balance") is None: + return + + self.update_first_trxn(trxn) + self.update_latest_trxn(trxn) + + # See if transactions have a consistent ascending/descending order + if self.last_trxn is not None: + self.check_date_order(trxn) + self.check_balance_order(trxn) + + # Now we have a new last transaction (this one) + self.update_last_trxn(trxn) + + def update_first_trxn(self, trxn): + """Ending balance will be here if transactions in descending order""" + if self.first_trxn is None: + self.first_trxn = trxn + + def update_latest_trxn(self, trxn): + """See if we can find one transaction with the latest date + + Args: + trxn (dict): the transaction + + Examples: + >>> ofx = OFX() + >>> trxn1 = { 'date': dt(2010, 6, 12, 0, 0) } + >>> trxn2 = { 'date': dt(2010, 6, 12, 0, 0) } + >>> trxn3 = { 'date': dt(2010, 6, 13, 0, 0) } + >>> ofx.update_latest_trxn(trxn1) + >>> ofx.latest_trxn['date'] == dt(2010, 6, 12, 0, 0) + True + >>> ofx.latest_date_count + 1 + >>> ofx.update_latest_trxn(trxn2) + >>> ofx.latest_trxn['date'] == dt(2010, 6, 12, 0, 0) + True + >>> ofx.latest_date_count + 2 + >>> ofx.update_latest_trxn(trxn3) + >>> ofx.latest_trxn['date'] == dt(2010, 6, 13, 0, 0) + True + >>> ofx.latest_date_count + 1 + """ + if self.latest_trxn is None: + self.latest_trxn = trxn + self.latest_date_count = 1 + elif trxn['date'] > self.latest_trxn['date']: + self.latest_trxn = trxn + self.latest_date_count = 1 + elif trxn['date'] == self.latest_trxn['date']: + self.latest_date_count += 1 + + def check_date_order(self, trxn): + """See if dates have a consistent ascending/descending order + + Args: + trxn (dict): the transaction + + Examples: + >>> ofx = OFX() + >>> trxn1 = { 'date': dt(2010, 6, 12, 0, 0) } + >>> trxn2 = { 'date': dt(2010, 6, 12, 0, 0) } + >>> trxn3 = { 'date': dt(2010, 6, 13, 0, 0) } + >>> trxn4 = { 'date': dt(2010, 6, 11, 0, 0) } + >>> ofx.update_last_trxn(trxn1) + >>> ofx.check_date_order(trxn2) + >>> ofx.dates_ascending + 0 + >>> ofx.dates_descending + 0 + >>> ofx.update_last_trxn(trxn2) + >>> ofx.check_date_order(trxn3) + >>> ofx.dates_ascending + 1 + >>> ofx.dates_descending + 0 + >>> ofx.update_last_trxn(trxn3) + >>> ofx.check_date_order(trxn4) + >>> ofx.dates_ascending + 1 + >>> ofx.dates_descending + 1 + """ + if trxn['date'] > self.last_trxn['date']: + # Dates are consistent with ascending transaction order + self.dates_ascending += 1 + elif trxn['date'] < self.last_trxn['date']: + # Dates are consistent with descending transaction order + self.dates_descending += 1 + + def check_balance_order(self, trxn): + """See if balances are consistent with ascending/descending order + + Args: + trxn (dict): the transaction + + Examples: + >>> ofx = OFX() + >>> trxn1 = { 'amount': 10, 'balance': 100 } + >>> trxn2 = { 'amount': 10, 'balance': 100 } + >>> trxn3 = { 'amount': 10, 'balance': 110 } + >>> trxn4 = { 'amount': 10, 'balance': 100 } + >>> trxn5 = { 'amount': -10, 'balance': 90 } + >>> ofx.update_last_trxn(trxn1) + >>> ofx.check_balance_order(trxn2) + >>> ofx.balances_ascending + 0 + >>> ofx.balances_descending + 0 + >>> ofx.update_last_trxn(trxn2) + >>> ofx.check_balance_order(trxn3) + >>> ofx.balances_ascending + 1 + >>> ofx.balances_descending + 0 + >>> ofx.update_last_trxn(trxn3) + >>> ofx.check_balance_order(trxn4) + >>> ofx.balances_ascending + 1 + >>> ofx.balances_descending + 1 + >>> ofx.update_last_trxn(trxn4) + >>> ofx.check_balance_order(trxn5) + >>> ofx.balances_ascending + 2 + >>> ofx.balances_descending + 2 + """ + # Note: Both of these could be true for a given transaction pair + if self.last_trxn["balance"] + trxn['amount'] == \ + trxn['balance']: + # Balances appear consistent with ascending transaction order + self.balances_ascending += 1 + if trxn.get("balance") + self.last_trxn['amount'] == \ + self.last_trxn['balance']: + # Balances appear consistent with descending transaction order + self.balances_descending += 1 + + def update_last_trxn(self, trxn): + """Ending balance will be here if transactions are in ascending order""" + self.last_trxn = trxn + def gen_groups(self, records, chunksize=None): """Generate the OFX groups""" for chnk in chunk(records, chunksize): diff --git a/csv2ofx/qif.py b/csv2ofx/qif.py index f482b55..7df4f2b 100644 --- a/csv2ofx/qif.py +++ b/csv2ofx/qif.py @@ -98,7 +98,8 @@ def transaction_data(self, tr): ... 'date': dt(2010, 6, 12, 0, 0), 'category': '', ... 'bank_id': 'e268443e43d93dab7ebef303bbe9642f', ... 'price': Decimal('0'), 'symbol': '', 'check_num': None, - ... 'inv_split_account': None, 'x_action': '', 'type': 'DEBIT'} + ... 'inv_split_account': None, 'x_action': '', 'type': 'DEBIT', + ... 'balance': None} True """ data = super(QIF, self).transaction_data(tr) diff --git a/data/converted/ingesp.ofx b/data/converted/ingesp.ofx index 7009cd1..534c10e 100644 --- a/data/converted/ingesp.ofx +++ b/data/converted/ingesp.ofx @@ -7,7 +7,7 @@ ENCODING:UTF-8 0 INFO - 20230108223613 + 20161031112908 ENG diff --git a/data/converted/schwab-checking-baltest-case1.ofx b/data/converted/schwab-checking-baltest-case1.ofx new file mode 100644 index 0000000..3a258c1 --- /dev/null +++ b/data/converted/schwab-checking-baltest-case1.ofx @@ -0,0 +1,75 @@ +DATA:OFXSGML +ENCODING:UTF-8 + + + + + 0 + INFO + + 20161031112908 + ENG + + + + + + + 0 + INFO + + + USD + + 121202211 + 12345 + CHECKING + + + 19700101 + 20220905 + + CREDIT + 20220817000000 + 20.00 + d68ff9d88e633aaf2421a50077f848fe + + Deposit Mobile Banking + Deposit Mobile Banking + + + DEBIT + 20220814000000 + -103.00 + c38117af9e65d7f8b751956b1f1e4173 + + BMO HARRIS BANK + BMO HARRIS BANK + + + DEBIT + 20220809000000 + -75.00 + 558 + 558 + Check Paid #558 + Check Paid #558 + + + DEBIT + 20220804000000 + -57.27 + a4ce828223f505323e618e1976ecc466 + + PAYPAL INST XFER 220803~ Tran: ACHDW + PAYPAL INST XFER 220803~ Tran: ACHDW + + + + 878.47 + 20220817000000 + + + + + diff --git a/data/converted/schwab-checking-baltest-case2.ofx b/data/converted/schwab-checking-baltest-case2.ofx new file mode 100644 index 0000000..fca0bc8 --- /dev/null +++ b/data/converted/schwab-checking-baltest-case2.ofx @@ -0,0 +1,71 @@ +DATA:OFXSGML +ENCODING:UTF-8 + + + + + 0 + INFO + + 20161031112908 + ENG + + + + + + + 0 + INFO + + + USD + + 121202211 + 12345 + CHECKING + + + 19700101 + 20220905 + + CREDIT + 20220817000000 + 20.00 + d68ff9d88e633aaf2421a50077f848fe + + Deposit Mobile Banking + Deposit Mobile Banking + + + DEBIT + 20220817000000 + -103.00 + ef9cd802919a077c705da67d059285fa + + BMO HARRIS BANK + BMO HARRIS BANK + + + DEBIT + 20220804000000 + -57.27 + a4ce828223f505323e618e1976ecc466 + + PAYPAL INST XFER 220803~ Tran: ACHDW + PAYPAL INST XFER 220803~ Tran: ACHDW + + + DEBIT + 20220809000000 + -75.00 + 558 + 558 + Check Paid #558 + Check Paid #558 + + + + + + diff --git a/data/converted/schwab-checking-baltest-case3.ofx b/data/converted/schwab-checking-baltest-case3.ofx new file mode 100644 index 0000000..dc611ef --- /dev/null +++ b/data/converted/schwab-checking-baltest-case3.ofx @@ -0,0 +1,75 @@ +DATA:OFXSGML +ENCODING:UTF-8 + + + + + 0 + INFO + + 20161031112908 + ENG + + + + + + + 0 + INFO + + + USD + + 121202211 + 12345 + CHECKING + + + 19700101 + 20220905 + + DEBIT + 20220804000000 + -57.27 + a4ce828223f505323e618e1976ecc466 + + PAYPAL INST XFER 220803~ Tran: ACHDW + PAYPAL INST XFER 220803~ Tran: ACHDW + + + DEBIT + 20220809000000 + -75.00 + 558 + 558 + Check Paid #558 + Check Paid #558 + + + DEBIT + 20220817000000 + -103.00 + ef9cd802919a077c705da67d059285fa + + BMO HARRIS BANK + BMO HARRIS BANK + + + CREDIT + 20220817000000 + 20.00 + d68ff9d88e633aaf2421a50077f848fe + + Deposit Mobile Banking + Deposit Mobile Banking + + + + 878.47 + 20220817000000 + + + + + diff --git a/data/converted/schwab-checking-baltest-case4.ofx b/data/converted/schwab-checking-baltest-case4.ofx new file mode 100644 index 0000000..263faff --- /dev/null +++ b/data/converted/schwab-checking-baltest-case4.ofx @@ -0,0 +1,75 @@ +DATA:OFXSGML +ENCODING:UTF-8 + + + + + 0 + INFO + + 20161031112908 + ENG + + + + + + + 0 + INFO + + + USD + + 121202211 + 12345 + CHECKING + + + 19700101 + 20220905 + + CREDIT + 20220817000000 + 20.00 + d68ff9d88e633aaf2421a50077f848fe + + Deposit Mobile Banking + Deposit Mobile Banking + + + DEBIT + 20220817000000 + -103.00 + ef9cd802919a077c705da67d059285fa + + BMO HARRIS BANK + BMO HARRIS BANK + + + DEBIT + 20220809000000 + -75.00 + 558 + 558 + Check Paid #558 + Check Paid #558 + + + DEBIT + 20220804000000 + -57.27 + a4ce828223f505323e618e1976ecc466 + + PAYPAL INST XFER 220803~ Tran: ACHDW + PAYPAL INST XFER 220803~ Tran: ACHDW + + + + 878.47 + 20220817000000 + + + + + diff --git a/data/converted/schwab-checking-baltest-case5.ofx b/data/converted/schwab-checking-baltest-case5.ofx new file mode 100644 index 0000000..24282de --- /dev/null +++ b/data/converted/schwab-checking-baltest-case5.ofx @@ -0,0 +1,75 @@ +DATA:OFXSGML +ENCODING:UTF-8 + + + + + 0 + INFO + + 20161031112908 + ENG + + + + + + + 0 + INFO + + + USD + + 121202211 + 12345 + CHECKING + + + 19700101 + 20220905 + + DEBIT + 20220817000000 + -57.27 + ea1f298fe817eb848e17b13e4b94b751 + + PAYPAL INST XFER 220803~ Tran: ACHDW + PAYPAL INST XFER 220803~ Tran: ACHDW + + + DEBIT + 20220817000000 + -75.00 + 558 + 558 + Check Paid #558 + Check Paid #558 + + + DEBIT + 20220817000000 + -103.00 + ef9cd802919a077c705da67d059285fa + + BMO HARRIS BANK + BMO HARRIS BANK + + + CREDIT + 20220817000000 + 20.00 + d68ff9d88e633aaf2421a50077f848fe + + Deposit Mobile Banking + Deposit Mobile Banking + + + + 878.47 + 20220817000000 + + + + + diff --git a/data/converted/schwab-checking-baltest-case6.ofx b/data/converted/schwab-checking-baltest-case6.ofx new file mode 100644 index 0000000..e4e27ef --- /dev/null +++ b/data/converted/schwab-checking-baltest-case6.ofx @@ -0,0 +1,75 @@ +DATA:OFXSGML +ENCODING:UTF-8 + + + + + 0 + INFO + + 20161031112908 + ENG + + + + + + + 0 + INFO + + + USD + + 121202211 + 12345 + CHECKING + + + 19700101 + 20220905 + + CREDIT + 20220817000000 + 20.00 + d68ff9d88e633aaf2421a50077f848fe + + Deposit Mobile Banking + Deposit Mobile Banking + + + DEBIT + 20220817000000 + -103.00 + ef9cd802919a077c705da67d059285fa + + BMO HARRIS BANK + BMO HARRIS BANK + + + DEBIT + 20220817000000 + -75.00 + 558 + 558 + Check Paid #558 + Check Paid #558 + + + DEBIT + 20220817000000 + -57.27 + ea1f298fe817eb848e17b13e4b94b751 + + PAYPAL INST XFER 220803~ Tran: ACHDW + PAYPAL INST XFER 220803~ Tran: ACHDW + + + + 878.47 + 20220817000000 + + + + + diff --git a/data/converted/schwab-checking-baltest-case7.ofx b/data/converted/schwab-checking-baltest-case7.ofx new file mode 100644 index 0000000..b5bff07 --- /dev/null +++ b/data/converted/schwab-checking-baltest-case7.ofx @@ -0,0 +1,53 @@ +DATA:OFXSGML +ENCODING:UTF-8 + + + + + 0 + INFO + + 20161031112908 + ENG + + + + + + + 0 + INFO + + + USD + + 121202211 + 12345 + CHECKING + + + 19700101 + 20220905 + + DEBIT + 20220817000000 + -20.00 + 558 + 558 + Check Paid #558 + Check Paid #558 + + + CREDIT + 20220817000000 + 20.00 + d68ff9d88e633aaf2421a50077f848fe + + Deposit Mobile Banking + Deposit Mobile Banking + + + + + + diff --git a/data/converted/schwab-checking-msmoney.ofx b/data/converted/schwab-checking-msmoney.ofx new file mode 100644 index 0000000..cc00bd3 --- /dev/null +++ b/data/converted/schwab-checking-msmoney.ofx @@ -0,0 +1,79 @@ +OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + 0 + INFO + + 20161031112908 + ENG + + + + + 1 + + 0 + INFO + + + USD + + 121202211 + 12345 + CHECKING + + + 19700101120000 + 20220905120000 + + CREDIT + 20220817120000 + 20.00 + d68ff9d88e633aaf2421a50077f848fe + Deposit Mobile Banking + Deposit Mobile Banking + + + DEBIT + 20220814120000 + -103.00 + c38117af9e65d7f8b751956b1f1e4173 + BMO HARRIS BANK + BMO HARRIS BANK + + + DEBIT + 20220809120000 + -75.00 + 558 + 558 + Check Paid #558 + Check Paid #558 + + + DEBIT + 20220804120000 + -57.27 + a4ce828223f505323e618e1976ecc466 + PAYPAL INST XFER 220803~ Tran: A + PAYPAL INST XFER 220803~ Tran: ACHDW + + + + 878.47 + 20220817120000 + + + + + diff --git a/data/converted/schwab-checking.ofx b/data/converted/schwab-checking.ofx new file mode 100644 index 0000000..3a258c1 --- /dev/null +++ b/data/converted/schwab-checking.ofx @@ -0,0 +1,75 @@ +DATA:OFXSGML +ENCODING:UTF-8 + + + + + 0 + INFO + + 20161031112908 + ENG + + + + + + + 0 + INFO + + + USD + + 121202211 + 12345 + CHECKING + + + 19700101 + 20220905 + + CREDIT + 20220817000000 + 20.00 + d68ff9d88e633aaf2421a50077f848fe + + Deposit Mobile Banking + Deposit Mobile Banking + + + DEBIT + 20220814000000 + -103.00 + c38117af9e65d7f8b751956b1f1e4173 + + BMO HARRIS BANK + BMO HARRIS BANK + + + DEBIT + 20220809000000 + -75.00 + 558 + 558 + Check Paid #558 + Check Paid #558 + + + DEBIT + 20220804000000 + -57.27 + a4ce828223f505323e618e1976ecc466 + + PAYPAL INST XFER 220803~ Tran: ACHDW + PAYPAL INST XFER 220803~ Tran: ACHDW + + + + 878.47 + 20220817000000 + + + + + diff --git a/data/test/schwab-checking-baltest-case1.csv b/data/test/schwab-checking-baltest-case1.csv new file mode 100644 index 0000000..3e1fee4 --- /dev/null +++ b/data/test/schwab-checking-baltest-case1.csv @@ -0,0 +1,8 @@ +"Transactions for Checking account ...123 as of 08/20/2022 04:04:41 PM ET" +"Date","Type","Check #","Description","Withdrawal (-)","Deposit (+)","RunningBalance" +"Pending Transactions are not reflected within this sort criterion." +"Posted Transactions" +"08/17/2022","DEPOSIT","","Deposit Mobile Banking","","$20.00","$878.47" +"08/14/2022","ATM","","BMO HARRIS BANK","$103.00","","$858.47" +"08/09/2022","CHECK","558","Check Paid #558","$75.00","","$961.47" +"08/04/2022","ACH","","PAYPAL INST XFER 220803~ Tran: ACHDW","$57.27","","$1,036.47" diff --git a/data/test/schwab-checking-baltest-case2.csv b/data/test/schwab-checking-baltest-case2.csv new file mode 100644 index 0000000..9707ae0 --- /dev/null +++ b/data/test/schwab-checking-baltest-case2.csv @@ -0,0 +1,8 @@ +"Transactions for Checking account ...123 as of 08/20/2022 04:04:41 PM ET" +"Date","Type","Check #","Description","Withdrawal (-)","Deposit (+)","RunningBalance" +"Pending Transactions are not reflected within this sort criterion." +"Posted Transactions" +"08/17/2022","DEPOSIT","","Deposit Mobile Banking","","$20.00","$878.47" +"08/17/2022","ATM","","BMO HARRIS BANK","$103.00","","$858.47" +"08/04/2022","ACH","","PAYPAL INST XFER 220803~ Tran: ACHDW","$57.27","","$1,036.47" +"08/09/2022","CHECK","558","Check Paid #558","$75.00","","$961.47" diff --git a/data/test/schwab-checking-baltest-case3.csv b/data/test/schwab-checking-baltest-case3.csv new file mode 100644 index 0000000..085ab42 --- /dev/null +++ b/data/test/schwab-checking-baltest-case3.csv @@ -0,0 +1,8 @@ +"Transactions for Checking account ...123 as of 08/20/2022 04:04:41 PM ET" +"Date","Type","Check #","Description","Withdrawal (-)","Deposit (+)","RunningBalance" +"Pending Transactions are not reflected within this sort criterion." +"Posted Transactions" +"08/04/2022","ACH","","PAYPAL INST XFER 220803~ Tran: ACHDW","$57.27","","$1,036.47" +"08/09/2022","CHECK","558","Check Paid #558","$75.00","","$961.47" +"08/17/2022","ATM","","BMO HARRIS BANK","$103.00","","$858.47" +"08/17/2022","DEPOSIT","","Deposit Mobile Banking","","$20.00","$878.47" diff --git a/data/test/schwab-checking-baltest-case4.csv b/data/test/schwab-checking-baltest-case4.csv new file mode 100644 index 0000000..ea16b7f --- /dev/null +++ b/data/test/schwab-checking-baltest-case4.csv @@ -0,0 +1,8 @@ +"Transactions for Checking account ...123 as of 08/20/2022 04:04:41 PM ET" +"Date","Type","Check #","Description","Withdrawal (-)","Deposit (+)","RunningBalance" +"Pending Transactions are not reflected within this sort criterion." +"Posted Transactions" +"08/17/2022","DEPOSIT","","Deposit Mobile Banking","","$20.00","$878.47" +"08/17/2022","ATM","","BMO HARRIS BANK","$103.00","","$858.47" +"08/09/2022","CHECK","558","Check Paid #558","$75.00","","$961.47" +"08/04/2022","ACH","","PAYPAL INST XFER 220803~ Tran: ACHDW","$57.27","","$1,036.47" diff --git a/data/test/schwab-checking-baltest-case5.csv b/data/test/schwab-checking-baltest-case5.csv new file mode 100644 index 0000000..c1ae840 --- /dev/null +++ b/data/test/schwab-checking-baltest-case5.csv @@ -0,0 +1,8 @@ +"Transactions for Checking account ...123 as of 08/20/2022 04:04:41 PM ET" +"Date","Type","Check #","Description","Withdrawal (-)","Deposit (+)","RunningBalance" +"Pending Transactions are not reflected within this sort criterion." +"Posted Transactions" +"08/17/2022","ACH","","PAYPAL INST XFER 220803~ Tran: ACHDW","$57.27","","$1,036.47" +"08/17/2022","CHECK","558","Check Paid #558","$75.00","","$961.47" +"08/17/2022","ATM","","BMO HARRIS BANK","$103.00","","$858.47" +"08/17/2022","DEPOSIT","","Deposit Mobile Banking","","$20.00","$878.47" diff --git a/data/test/schwab-checking-baltest-case6.csv b/data/test/schwab-checking-baltest-case6.csv new file mode 100644 index 0000000..6b1125b --- /dev/null +++ b/data/test/schwab-checking-baltest-case6.csv @@ -0,0 +1,8 @@ +"Transactions for Checking account ...123 as of 08/20/2022 04:04:41 PM ET" +"Date","Type","Check #","Description","Withdrawal (-)","Deposit (+)","RunningBalance" +"Pending Transactions are not reflected within this sort criterion." +"Posted Transactions" +"08/17/2022","DEPOSIT","","Deposit Mobile Banking","","$20.00","$878.47" +"08/17/2022","ATM","","BMO HARRIS BANK","$103.00","","$858.47" +"08/17/2022","CHECK","558","Check Paid #558","$75.00","","$961.47" +"08/17/2022","ACH","","PAYPAL INST XFER 220803~ Tran: ACHDW","$57.27","","$1,036.47" diff --git a/data/test/schwab-checking-baltest-case7.csv b/data/test/schwab-checking-baltest-case7.csv new file mode 100644 index 0000000..a6a04fd --- /dev/null +++ b/data/test/schwab-checking-baltest-case7.csv @@ -0,0 +1,6 @@ +"Transactions for Checking account ...123 as of 08/20/2022 04:04:41 PM ET" +"Date","Type","Check #","Description","Withdrawal (-)","Deposit (+)","RunningBalance" +"Pending Transactions are not reflected within this sort criterion." +"Posted Transactions" +"08/17/2022","CHECK","558","Check Paid #558","$20.00","","$858.47" +"08/17/2022","DEPOSIT","","Deposit Mobile Banking","","$20.00","$878.47" diff --git a/data/test/schwab-checking.csv b/data/test/schwab-checking.csv new file mode 100644 index 0000000..3e1fee4 --- /dev/null +++ b/data/test/schwab-checking.csv @@ -0,0 +1,8 @@ +"Transactions for Checking account ...123 as of 08/20/2022 04:04:41 PM ET" +"Date","Type","Check #","Description","Withdrawal (-)","Deposit (+)","RunningBalance" +"Pending Transactions are not reflected within this sort criterion." +"Posted Transactions" +"08/17/2022","DEPOSIT","","Deposit Mobile Banking","","$20.00","$878.47" +"08/14/2022","ATM","","BMO HARRIS BANK","$103.00","","$858.47" +"08/09/2022","CHECK","558","Check Paid #558","$75.00","","$961.47" +"08/04/2022","ACH","","PAYPAL INST XFER 220803~ Tran: ACHDW","$57.27","","$1,036.47" diff --git a/tests/test.py b/tests/test.py index 15b5c92..18c0f87 100755 --- a/tests/test.py +++ b/tests/test.py @@ -158,6 +158,51 @@ def gen_test(raw): "ingesp.csv", "ingesp.ofx", ), + ( + ["-o", "-m schwabchecking", "-e 20220905", SERVER_DATE], + "schwab-checking.csv", + "schwab-checking.ofx", + ), + ( + ["-o", "-M", "-m schwabchecking", "-e 20220905", SERVER_DATE], + "schwab-checking.csv", + "schwab-checking-msmoney.ofx", + ), + ( + ["-o", "-m schwabchecking", "-e 20220905", SERVER_DATE], + "schwab-checking-baltest-case1.csv", + "schwab-checking-baltest-case1.ofx", + ), + ( + ["-o", "-m schwabchecking", "-e 20220905", SERVER_DATE], + "schwab-checking-baltest-case2.csv", + "schwab-checking-baltest-case2.ofx", + ), + ( + ["-o", "-m schwabchecking", "-e 20220905", SERVER_DATE], + "schwab-checking-baltest-case3.csv", + "schwab-checking-baltest-case3.ofx", + ), + ( + ["-o", "-m schwabchecking", "-e 20220905", SERVER_DATE], + "schwab-checking-baltest-case4.csv", + "schwab-checking-baltest-case4.ofx", + ), + ( + ["-o", "-m schwabchecking", "-e 20220905", SERVER_DATE], + "schwab-checking-baltest-case5.csv", + "schwab-checking-baltest-case5.ofx", + ), + ( + ["-o", "-m schwabchecking", "-e 20220905", SERVER_DATE], + "schwab-checking-baltest-case6.csv", + "schwab-checking-baltest-case6.ofx", + ), + ( + ["-o", "-m schwabchecking", "-e 20220905", SERVER_DATE], + "schwab-checking-baltest-case7.csv", + "schwab-checking-baltest-case7.ofx", + ), (["-o", "-m amazon", "-e 20230604", SERVER_DATE], "amazon.csv", "amazon.ofx",), ]