diff --git a/csv2ofx/__init__.py b/csv2ofx/__init__.py index 235d5ae..4baf807 100644 --- a/csv2ofx/__init__.py +++ b/csv2ofx/__init__.py @@ -27,6 +27,7 @@ from functools import partial from datetime import datetime as dt from operator import itemgetter +from decimal import Decimal from builtins import * from six.moves import filterfalse @@ -40,15 +41,17 @@ __author__ = 'Reuben Cummings' __description__ = 'converts a csv file of transactions to an ofx or qif file' __email__ = 'reubano@gmail.com' -__version__ = '0.19.3' +__version__ = '0.20.0' __license__ = 'MIT' __copyright__ = 'Copyright 2015 Reuben Cummings' - +# pylint: disable=invalid-name md5 = lambda content: hashlib.md5(content.encode('utf-8')).hexdigest() -class Content(object): +class Content(object): # pylint: disable=too-many-instance-attributes + """A transaction holding object + """ def __init__(self, mapping=None, **kwargs): """ Base content constructor Args: @@ -56,7 +59,6 @@ def __init__(self, mapping=None, **kwargs): kwargs (dict): Keyword arguments Kwargs: - split_header (str): Transaction field to use for the split account. start (date): Date from which to begin including transactions. end (date): Date from which to exclude transactions. @@ -66,27 +68,33 @@ def __init__(self, mapping=None, **kwargs): """ mapping = mapping or {} + + # pylint doesn't like dynamically set attributes... + self.amount = None + self.account = None + self.split_account = None + self.inv_split_account = None + self.id = None [self.__setattr__(k, v) for k, v in mapping.items()] if not hasattr(self, 'is_split'): self.is_split = False - if kwargs.get('split_header'): - self.split_account = itemgetter(kwargs['split_header']) - else: - self.split_account = None + if not callable(self.account): + account = self.account + self.account = lambda _: account self.start = kwargs.get('start') or dt(1970, 1, 1) self.end = kwargs.get('end') or dt.now() - def get(self, name, tr=None, default=None): + def get(self, name, trxn=None, default=None): """ Gets an attribute which could be either a normal attribute, a mapping function, or a mapping attribute Args: name (str): The attribute. - tr (dict): The transaction. Require if `name` is a mapping function - (default: None). + trxn (dict): The transaction. Require if `name` is a mapping + function (default: None). default (str): Value to use if `name` isn't found (default: None). @@ -99,11 +107,11 @@ def get(self, name, tr=None, default=None): >>> from datetime import datetime as dt >>> from csv2ofx.mappings.mint import mapping >>> - >>> tr = {'Transaction Type': 'debit', 'Amount': 1000.00} + >>> trxn = {'Transaction Type': 'DEBIT', 'Amount': 1000.00} >>> start = dt(2015, 1, 1) >>> Content(mapping, start=start).get('start') # normal attribute datetime.datetime(2015, 1, 1, 0, 0) - >>> Content(mapping).get('amount', tr) # mapping function + >>> Content(mapping).get('amount', trxn) # mapping function 1000.0 >>> Content(mapping).get('has_header') # mapping attribute True @@ -117,7 +125,7 @@ def get(self, name, tr=None, default=None): value = None try: - value = value or attr(tr) if attr else default + value = value or attr(trxn) if attr else default except TypeError: value = attr except KeyError: @@ -125,12 +133,12 @@ def get(self, name, tr=None, default=None): return value - def skip_transaction(self, tr): + def skip_transaction(self, trxn): """ Determines whether a transaction should be skipped (isn't in the specified date range) Args: - tr (dict): The transaction. + trxn (dict): The transaction. Returns: (bool): Whether or not to skip the transaction. @@ -139,19 +147,19 @@ def skip_transaction(self, tr): >>> from csv2ofx.mappings.mint import mapping >>> from datetime import datetime as dt >>> - >>> tr = {'Date': '06/12/10', 'Amount': 1000.00} - >>> Content(mapping, start=dt(2010, 1, 1)).skip_transaction(tr) + >>> trxn = {'Date': '06/12/10', 'Amount': 1000.00} + >>> Content(mapping, start=dt(2010, 1, 1)).skip_transaction(trxn) False - >>> Content(mapping, start=dt(2013, 1, 1)).skip_transaction(tr) + >>> Content(mapping, start=dt(2013, 1, 1)).skip_transaction(trxn) True """ - return not (self.end >= parse(self.get('date', tr)) >= self.start) + return not self.end >= parse(self.get('date', trxn)) >= self.start - def convert_amount(self, tr): + def convert_amount(self, trxn): """ Converts a string amount into a number Args: - tr (dict): The transaction. + trxn (dict): The transaction. Returns: (decimal): The converted amount. @@ -161,17 +169,17 @@ def convert_amount(self, tr): >>> from datetime import datetime as dt >>> from csv2ofx.mappings.mint import mapping >>> - >>> tr = {'Date': '06/12/10', 'Amount': '$1,000'} - >>> Content(mapping, start=dt(2010, 1, 1)).convert_amount(tr) + >>> trxn = {'Date': '06/12/10', 'Amount': '$1,000'} + >>> Content(mapping, start=dt(2010, 1, 1)).convert_amount(trxn) Decimal('1000.00') """ - return utils.convert_amount(self.get('amount', tr)) + return utils.convert_amount(self.get('amount', trxn)) - def transaction_data(self, tr): + def transaction_data(self, trxn): # pylint: disable=too-many-locals """ gets transaction data Args: - tr (dict): the transaction + trxn (dict): the transaction Returns: (dict): the QIF content @@ -180,69 +188,96 @@ def transaction_data(self, tr): >>> import datetime >>> from decimal import Decimal >>> from csv2ofx.mappings.mint import mapping - >>> tr = {'Transaction Type': 'debit', 'Amount': 1000.00, \ -'Date': '06/12/10', 'Description': 'payee', 'Original Description': \ -'description', 'Notes': 'notes', 'Category': 'Checking', 'Account Name': \ -'account'} - >>> Content(mapping).transaction_data(tr) == { + >>> trxn = { + ... 'Transaction Type': 'DEBIT', 'Amount': 1000.00, + ... 'Date': '06/12/10', 'Description': 'payee', + ... 'Original Description': 'description', 'Notes': 'notes', + ... 'Category': 'Checking', 'Account Name': 'account'} + >>> Content(mapping).transaction_data(trxn) == { ... 'account_id': 'e268443e43d93dab7ebef303bbe9642f', - ... 'memo': 'description notes', 'split_account_id': - ... None, 'currency': 'USD', - ... 'date': datetime.datetime(2010, 6, 12, 0, 0), - ... 'class': None, 'bank': 'account', 'account': 'account', - ... 'split_account': None, ... 'bank_id': 'e268443e43d93dab7ebef303bbe9642f', - ... 'id': 'ee86450a47899254e2faa82dca3c2cf2', 'payee': 'payee', - ... 'amount': Decimal('-1000.00'), 'check_num': None, - ... 'type': 'debit'} + ... 'account': 'account', + ... 'split_account_id': '195917574edc9b6bbeb5be9785b6a479', + ... 'shares': Decimal('0'), 'payee': 'payee', 'currency': 'USD', + ... 'bank': 'account', 'class': None, 'is_investment': False, + ... 'date': datetime.datetime(2010, 6, 12, 0, 0), + ... 'price': Decimal('0'), 'symbol': '', 'action': '', + ... 'check_num': None, 'id': 'ee86450a47899254e2faa82dca3c2cf2', + ... 'split_account': 'Checking', 'type': 'DEBIT', + ... 'category': '', 'amount': Decimal('-1000.00'), + ... 'memo': 'description notes', 'inv_split_account': None, + ... 'x_action': ''} True """ - account = self.get('account', tr) - split_account = self.get('split_account', tr) - bank = self.get('bank', tr, account) - - raw_amount = str(self.get('amount', tr)) - amount = self.convert_amount(tr) - _type = self.get('type', tr) - - if _type: - amount = -1 * amount if _type.lower() == 'debit' else amount - else: + account = self.get('account', trxn) + split_account = self.get('split_account', trxn) + bank = self.get('bank', trxn, account) + raw_amount = str(self.get('amount', trxn)) + amount = self.convert_amount(trxn) + _type = self.get('type', trxn, '').upper() + + if _type not in {'DEBIT', 'CREDIT'}: _type = 'CREDIT' if amount > 0 else 'DEBIT' - date = self.get('date', tr) - payee = self.get('payee', tr) - desc = self.get('desc', tr) - notes = self.get('notes', tr) + date = self.get('date', trxn) + payee = self.get('payee', trxn) + desc = self.get('desc', trxn) + notes = self.get('notes', trxn) memo = '%s %s' % (desc, notes) if desc and notes else desc or notes - check_num = self.get('check_num', tr) + check_num = self.get('check_num', trxn) details = ''.join(filter(None, [date, raw_amount, payee, memo])) + category = self.get('category', trxn, '') + shares = Decimal(self.get('shares', trxn, 0)) + symbol = self.get('symbol', trxn, '') + price = Decimal(self.get('price', trxn, 0)) + invest = shares or (symbol and symbol != 'N/A') or 'invest' in category + + if invest: + amount = abs(amount) + shares = shares or (amount / price) if price else shares + amount = amount or shares * price + price = price or (amount / shares) if shares else price + action = utils.get_action(category) + x_action = utils.get_action(category, True) + else: + amount = -1 * abs(amount) if _type == 'DEBIT' else abs(amount) + action = '' + x_action = '' return { 'date': parse(date), - 'currency': self.get('currency', tr, 'USD'), + 'currency': self.get('currency', trxn, 'USD'), + 'shares': shares, + 'symbol': symbol, + 'price': price, + 'action': action, + 'x_action': x_action, + 'category': category, + 'is_investment': invest, 'bank': bank, - 'bank_id': self.get('bank_id', tr, md5(bank)), + 'bank_id': self.get('bank_id', trxn, md5(bank)), 'account': account, - 'account_id': self.get('account_id', tr, md5(account)), + 'account_id': self.get('account_id', trxn, md5(account)), 'split_account': split_account, + 'inv_split_account': self.get('inv_split_account', trxn), 'split_account_id': md5(split_account) if split_account else None, 'amount': amount, 'payee': payee, 'memo': memo, - 'class': self.get('class', tr), - 'id': self.get('id', tr, check_num) or md5(details), + 'class': self.get('class', trxn), + 'id': self.get('id', trxn, check_num) or md5(details), 'check_num': check_num, 'type': _type, } def gen_trxns(self, groups, collapse=False): + """ Generate transactions """ for grp, transactions in groups: if self.is_split and collapse: # group transactions by `collapse` field and sum the amounts byaccount = group(transactions, collapse) - op = lambda values: sum(map(utils.convert_amount, values)) - merger = partial(merge, pred=self.amount, op=op) + oprtn = lambda values: sum(map(utils.convert_amount, values)) + merger = partial(merge, pred=self.amount, op=oprtn) trxns = [merger(dicts) for _, dicts in byaccount] else: trxns = transactions @@ -250,10 +285,11 @@ def gen_trxns(self, groups, collapse=False): yield (grp, trxns) def clean_trxns(self, groups): + """ Clean transactions """ for grp, trxns in groups: _args = [trxns, self.convert_amount] - # if it's split, transactions skipping is all or none + # if it's split, transaction skipping is all or none if self.is_split and self.skip_transaction(trxns[0]): continue elif self.is_split and not utils.verify_splits(*_args): @@ -268,6 +304,7 @@ def clean_trxns(self, groups): else: main_pos = 0 + # pylint: disable=cell-var-from-loop keyfunc = lambda enum: enum[0] != main_pos sorted_trxns = sorted(enumerate(filtered_trxns), key=keyfunc) yield (grp, main_pos, sorted_trxns) diff --git a/csv2ofx/main.py b/csv2ofx/main.py index 8b9a9c1..b9d9a40 100755 --- a/csv2ofx/main.py +++ b/csv2ofx/main.py @@ -44,7 +44,7 @@ from .qif import QIF -parser = ArgumentParser( # pylint: disable=C0103 +parser = ArgumentParser( # pylint: disable=invalid-name description="description: csv2ofx converts a csv file to ofx and qif", prog='csv2ofx', usage='%(prog)s [options] ', formatter_class=RawTextHelpFormatter) @@ -76,9 +76,6 @@ '-c', '--collapse', metavar='FIELD_NAME', help=( 'field used to combine transactions within a split for double entry ' 'statements')) -parser.add_argument( - '-S', '--split', metavar='FIELD_NAME', help=( - 'field used for the split account for single entry statements')) parser.add_argument( '-C', '--chunksize', metavar='ROWS', type=int, default=2 ** 14, help="number of rows to process at a time") @@ -135,7 +132,6 @@ def run(): # noqa: C901 okwargs = { 'def_type': args.account_type or 'Bank' if args.qif else 'CHECKING', - 'split_header': args.split, 'start': parse(args.start) if args.start else None, 'end': parse(args.end) if args.end else None } @@ -156,7 +152,7 @@ def run(): # noqa: C901 else: try: mtime = p.getmtime(source.name) - except AttributeError: + except (AttributeError, FileNotFoundError): mtime = time.time() server_date = dt.fromtimestamp(mtime) @@ -169,9 +165,9 @@ def run(): # noqa: C901 'overwrite': args.overwrite, 'chunksize': args.chunksize, 'encoding': args.encoding} - except: + except Exception as err: # pylint: disable=broad-except source.close() - raise + exit(err) dest = open(args.dest, 'w', encoding=args.encoding) if args.dest else stdout @@ -186,6 +182,10 @@ def run(): # noqa: C901 msg += 'Check `start` and `end` options.' else: msg += 'Try again with `-c` option.' + except ValueError: + # csv2ofx called with no arguments + msg = 0 + parser.print_help() except Exception as err: # pylint: disable=broad-except msg = 1 traceback.print_exc() diff --git a/csv2ofx/mappings/mint.py b/csv2ofx/mappings/mint.py index da8ba08..4a50e26 100644 --- a/csv2ofx/mappings/mint.py +++ b/csv2ofx/mappings/mint.py @@ -1,3 +1,12 @@ +# -*- coding: utf-8 -*- +# vim: sw=4:ts=4:expandtab +# pylint: disable=invalid-name +""" +csv2ofx.mappings.mintapi +~~~~~~~~~~~~~~~~~~~~~~~~ + +Provides a mapping for transactions obtained via mint.com +""" from __future__ import absolute_import from operator import itemgetter @@ -5,6 +14,7 @@ mapping = { 'is_split': False, 'has_header': True, + 'split_account': itemgetter('Category'), 'account': itemgetter('Account Name'), 'date': itemgetter('Date'), 'type': itemgetter('Transaction Type'), diff --git a/csv2ofx/mappings/split_account.py b/csv2ofx/mappings/split_account.py new file mode 100644 index 0000000..db19eea --- /dev/null +++ b/csv2ofx/mappings/split_account.py @@ -0,0 +1,20 @@ +from __future__ import absolute_import + +from operator import itemgetter + +mapping = { + 'has_header': True, + 'is_split': False, + 'bank': 'Bank', + 'currency': 'USD', + 'delimiter': ',', + 'split_account': itemgetter('Category'), + 'account': itemgetter('Account'), + 'date': itemgetter('Date'), + 'amount': itemgetter('Amount'), + 'desc': itemgetter('Reference'), + 'payee': itemgetter('Description'), + 'notes': itemgetter('Notes'), + 'check_num': itemgetter('Num'), + 'id': itemgetter('Row'), +} diff --git a/csv2ofx/ofx.py b/csv2ofx/ofx.py index 74de297..06327f1 100644 --- a/csv2ofx/ofx.py +++ b/csv2ofx/ofx.py @@ -1,6 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # vim: sw=4:ts=4:expandtab +# pylint: disable=no-self-use """ csv2ofx.ofx @@ -30,6 +31,7 @@ class OFX(Content): + """ An OFX object """ def __init__(self, mapping=None, **kwargs): """ OFX constructor Args: @@ -38,7 +40,6 @@ def __init__(self, mapping=None, **kwargs): Kwargs: def_type (str): Default account type. - split_header (str): Transaction field to use for the split account. start (date): Date from which to begin including transactions. end (date): Date from which to exclude transactions. @@ -47,7 +48,7 @@ def __init__(self, mapping=None, **kwargs): >>> OFX(mapping) # doctest: +ELLIPSIS """ - # TODO: Add timezone info + # TODO: Add timezone info # pylint: disable=fixme super(OFX, self).__init__(mapping, **kwargs) self.resp_type = 'INTRATRNRS' if self.split_account else 'STMTTRNRS' self.def_type = kwargs.get('def_type') @@ -107,11 +108,11 @@ def header(self, **kwargs): content += '\t\t\t\n' return content - def transaction_data(self, tr): + def transaction_data(self, trxn): """ gets OFX transaction data Args: - tr (dict): the transaction + trxn (dict): the transaction Returns: (dict): the OFX transaction data @@ -120,36 +121,35 @@ def transaction_data(self, tr): >>> import datetime >>> from csv2ofx.mappings.mint import mapping >>> from decimal import Decimal - >>> tr = { - ... 'Transaction Type': 'debit', 'Amount': 1000.00, + >>> trxn = { + ... 'Transaction Type': 'DEBIT', 'Amount': 1000.00, ... 'Date': '06/12/10', 'Description': 'payee', ... 'Original Description': 'description', 'Notes': 'notes', ... 'Category': 'Checking', 'Account Name': 'account'} - >>> OFX(mapping, def_type='CHECKING').transaction_data(tr) == { - ... 'account_type': 'CHECKING', + >>> OFX(mapping, def_type='CHECKING').transaction_data(trxn) == { ... 'account_id': 'e268443e43d93dab7ebef303bbe9642f', - ... 'memo': 'description notes', 'split_account_id': None, - ... 'currency': 'USD', - ... 'date': datetime.datetime(2010, 6, 12, 0, 0), - ... 'class': None, 'bank': 'account', 'account': 'account', - ... 'split_account': None, + ... 'account': 'account', 'currency': 'USD', + ... 'account_type': 'CHECKING', 'shares': Decimal('0'), + ... 'is_investment': False, 'bank': 'account', + ... 'split_account_type': 'CHECKING', + ... 'split_account_id': '195917574edc9b6bbeb5be9785b6a479', + ... 'class': None, 'amount': Decimal('-1000.00'), + ... 'memo': 'description notes', + ... 'id': 'ee86450a47899254e2faa82dca3c2cf2', + ... 'split_account': 'Checking', 'action': '', 'payee': 'payee', + ... 'date': dt(2010, 6, 12, 0, 0), 'category': '', ... 'bank_id': 'e268443e43d93dab7ebef303bbe9642f', - ... 'id': 'ee86450a47899254e2faa82dca3c2cf2', 'payee': 'payee', - ... 'amount': Decimal('-1000.00'), 'split_account_type': None, - ... 'check_num': None, 'type': 'debit'} + ... 'price': Decimal('0'), 'symbol': '', 'check_num': None, + ... 'inv_split_account': None, 'x_action': '', 'type': 'DEBIT'} True """ - data = super(OFX, self).transaction_data(tr) + data = super(OFX, self).transaction_data(trxn) args = [self.account_types, self.def_type] - sa = data['split_account'] - sa_type = utils.get_account_type(sa, *args) if sa else None + split = data['split_account'] + sa_type = utils.get_account_type(split, *args) if split else None memo = data.get('memo') _class = data.get('class') - - if memo and _class: - memo = '%s %s' % (memo, _class) - else: - memo = memo or _class + memo = '%s %s' % (memo, _class) if memo and _class else memo or _class new_data = { 'account_type': utils.get_account_type(data['account'], *args), @@ -221,13 +221,13 @@ def transaction(self, **kwargs): (str): the OFX content Examples: - >>> kwargs = {'date': dt(2012, 1, 15), 'type': 'debit', \ + >>> kwargs = {'date': dt(2012, 1, 15), 'type': 'DEBIT', \ 'amount': 100, 'id': 1, 'check_num': 1, 'payee': 'payee', 'memo': 'memo'} - >>> tr = 'debit20120115000000\ -100.0011\ -payeememo' + >>> trxn = 'DEBIT\ +20120115000000100.0011\ +payeememo' >>> result = OFX().transaction(**kwargs) - >>> tr == result.replace('\\n', '').replace('\\t', '') + >>> trxn == result.replace('\\n', '').replace('\\t', '') True """ time_stamp = kwargs['date'].strftime('%Y%m%d%H%M%S') # yyyymmddhhmmss @@ -300,11 +300,11 @@ def transfer(self, **kwargs): >>> kwargs = {'currency': 'USD', 'date': dt(2012, 1, 15), \ 'bank_id': 1, 'account_id': 1, 'account_type': 'CHECKING', 'amount': 100, \ 'id': 'jbaevf'} - >>> tr = 'USDjbaevf\ + >>> trxn = 'USDjbaevf\ 100.0011\ CHECKING' >>> result = OFX().transfer(**kwargs) - >>> tr == result.replace('\\n', '').replace('\\t', '') + >>> trxn == result.replace('\\n', '').replace('\\t', '') True """ content = '\t\t\t\n' @@ -385,6 +385,7 @@ def split_content(self, **kwargs): content += '\t\t\t\t\t\n' return content + # pylint: disable=unused-argument def transfer_end(self, date=None, **kwargs): """ Gets OFX transfer end @@ -434,17 +435,18 @@ def footer(self, **kwargs): return content def gen_body(self, data): # noqa: C901 - for gd in data: - group = gd['group'] + """ Generate the OFX body """ + for datum in data: + grp = datum['group'] - if self.is_split and gd['len'] > 2: + if self.is_split and datum['len'] > 2: # OFX doesn't support more than 2 splits - raise TypeError('Group %s has too many splits.\n' % group) + raise TypeError('Group %s has too many splits.\n' % grp) - trxn_data = self.transaction_data(gd['trxn']) + trxn_data = self.transaction_data(datum['trxn']) split_like = self.is_split or self.split_account full_split = self.is_split and self.split_account - new_group = self.prev_group and self.prev_group != group + new_group = self.prev_group and self.prev_group != grp if new_group and full_split: yield self.transfer_end(**trxn_data) @@ -455,23 +457,24 @@ def gen_body(self, data): # noqa: C901 yield self.transfer(**trxn_data) yield self.split_content(**trxn_data) yield self.transfer_end(**trxn_data) - elif self.is_split and gd['is_main']: + elif self.is_split and datum['is_main']: yield self.transfer(**trxn_data) elif self.is_split: yield self.split_content(**trxn_data) - elif gd['is_main']: + elif datum['is_main']: yield self.account_start(**trxn_data) yield self.transaction(**trxn_data) else: yield self.transaction(**trxn_data) - self.prev_group = group + self.prev_group = grp def gen_groups(self, records, chunksize=None): + """ Generate the OFX groups """ for chnk in chunk(records, chunksize): cleansed = [ {k: next(xmlize([v])) for k, v in c.items()} for c in chnk] keyfunc = self.id if self.is_split else self.account - for g in group(cleansed, keyfunc): - yield g + for gee in group(cleansed, keyfunc): + yield gee diff --git a/csv2ofx/qif.py b/csv2ofx/qif.py index b556ee7..b11104e 100644 --- a/csv2ofx/qif.py +++ b/csv2ofx/qif.py @@ -1,6 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # vim: sw=4:ts=4:expandtab +# pylint: disable=no-self-use """ csv2ofx.qif @@ -28,6 +29,7 @@ class QIF(Content): + """ A QIF object """ def __init__(self, mapping=None, **kwargs): """ QIF constructor Args: @@ -36,7 +38,6 @@ def __init__(self, mapping=None, **kwargs): Kwargs: def_type (str): Default account type. - split_header (str): Transaction field to use for the split account. start (date): Date from which to begin including transactions. end (date): Date from which to exclude transactions. @@ -50,6 +51,7 @@ def __init__(self, mapping=None, **kwargs): self.prev_account = None self.prev_group = None self.account_types = { + 'Invst': ('roth', 'ira', '401k', 'vanguard'), 'Bank': ('checking', 'savings', 'market', 'income'), 'Oth A': ('receivable',), 'Oth L': ('payable',), @@ -57,7 +59,8 @@ def __init__(self, mapping=None, **kwargs): 'Cash': ('cash', 'expenses') } - def header(self, **kwargs): + def header(self, **kwargs): # pylint: disable=unused-argument + """ Get the QIF header """ return None def transaction_data(self, tr): @@ -74,21 +77,26 @@ def transaction_data(self, tr): >>> from decimal import Decimal >>> from csv2ofx.mappings.mint import mapping >>> tr = { - ... 'Transaction Type': 'debit', 'Amount': 1000.00, + ... 'Transaction Type': 'DEBIT', 'Amount': 1000.00, ... 'Date': '06/12/10', 'Description': 'payee', ... 'Original Description': 'description', 'Notes': 'notes', ... 'Category': 'Checking', 'Account Name': 'account'} - >>> amount = Decimal('-1000.00') >>> QIF(mapping, def_type='Bank').transaction_data(tr) == { - ... 'account_type': 'Bank', ... 'account_id': 'e268443e43d93dab7ebef303bbe9642f', - ... 'memo': 'description notes', 'split_account_id': None, - ... 'currency': 'USD', 'date': dt(2010, 6, 12, 0, 0), - ... 'class': None, 'bank': 'account', 'account': 'account', - ... 'split_memo': 'description notes', 'split_account': None, + ... 'account': 'account', 'currency': 'USD', + ... 'account_type': 'Bank', 'shares': Decimal('0'), + ... 'is_investment': False, 'bank': 'account', + ... 'split_memo': 'description notes', 'split_account_id': None, + ... 'class': None, 'amount': Decimal('-1000.00'), + ... 'memo': 'description notes', + ... 'id': 'ee86450a47899254e2faa82dca3c2cf2', + ... 'split_account': 'Checking', + ... 'split_account_id': '195917574edc9b6bbeb5be9785b6a479', + ... 'action': '', 'payee': 'payee', + ... 'date': dt(2010, 6, 12, 0, 0), 'category': '', ... 'bank_id': 'e268443e43d93dab7ebef303bbe9642f', - ... 'id': 'ee86450a47899254e2faa82dca3c2cf2', 'payee': 'payee', - ... 'amount': amount, 'check_num': None, 'type': 'debit'} + ... 'price': Decimal('0'), 'symbol': '', 'check_num': None, + ... 'inv_split_account': None, 'x_action': '', 'type': 'DEBIT'} True """ data = super(QIF, self).transaction_data(tr) @@ -117,7 +125,7 @@ def account_start(self, **kwargs): Kwargs: account (str): The account name. account_type (str): The account type. One of ['Bank', 'Oth A', - 'Oth L', 'CCard', 'Cash'] (required). + 'Oth L', 'CCard', 'Cash', 'Invst'] (required). Returns: (str): the QIF content @@ -150,31 +158,46 @@ class (str): the transaction classification Examples: >>> from datetime import datetime as dt - >>> kwargs = {'payee': 'payee', 'amount': 100, 'check_num': 1, \ -'date': dt(2012, 1, 1), 'account_type': 'Bank'} - >>> tr = '!Type:BankN1D01/01/12PpayeeT100.00' + >>> kwargs = { + ... 'payee': 'payee', 'amount': 100, 'check_num': 1, + ... 'date': dt(2012, 1, 1), 'account_type': 'Bank'} + >>> trxn = '!Type:BankN1D01/01/12PpayeeT100.00' >>> result = QIF().transaction(**kwargs) - >>> tr == result.replace('\\n', '').replace('\\t', '') + >>> trxn == result.replace('\\n', '').replace('\\t', '') True """ kwargs.update({'time_stamp': kwargs['date'].strftime('%m/%d/%y')}) + is_investment = kwargs.get('is_investment') + is_transaction = not is_investment if self.is_split: kwargs.update({'amount': kwargs['amount'] * -1}) content = "!Type:%(account_type)s\n" % kwargs - if kwargs.get('check_num'): + if is_transaction and kwargs.get('check_num'): content += "N%(check_num)s\n" % kwargs content += "D%(time_stamp)s\n" % kwargs - content += "P%(payee)s\n" % kwargs - if kwargs.get('memo'): - content += "M%(memo)s\n" % kwargs + if is_investment: + if kwargs.get('inv_split_account'): + content += "N%(x_action)s\n" % kwargs + else: + content += "N%(action)s\n" % kwargs - if kwargs.get('class'): - content += "L%(class)s\n" % kwargs + content += "Y%(symbol)s\n" % kwargs + content += "I%(price)s\n" % kwargs + content += "Q%(shares)s\n" % kwargs + content += "Cc\n" + else: + content += "P%(payee)s\n" % kwargs if kwargs.get('payee') else '' + content += "L%(class)s\n" % kwargs if kwargs.get('class') else '' + + content += "M%(memo)s\n" % kwargs if kwargs.get('memo') else '' + + if is_investment and kwargs.get('commission'): + content += "O%(commission)s\n" % kwargs content += "T%(amount)0.2f\n" % kwargs return content @@ -189,8 +212,12 @@ def split_content(self, **kwargs): split_account (str): Account to use as the transfer recipient. (useful in cases when the transaction data isn't already split) - account (str): A unique account identifier (required if a - `split_account` isn't given). + inv_split_account (str): Account to use as the investment transfer + recipient. (useful in cases when the transaction data isn't + already split) + + account (str): A unique account identifier (required if neither + `split_account` nor `inv_split_account` is given). split_memo (str): the transaction split memo @@ -198,22 +225,31 @@ def split_content(self, **kwargs): (str): the QIF content Examples: - >>> kwargs = {'account': 'account', 'split_memo': 'memo', \ -'amount': 100} + >>> kwargs = { + ... 'account': 'account', 'split_memo': 'memo', 'amount': 100} >>> split = 'SaccountEmemo$100.00' >>> result = QIF().split_content(**kwargs) >>> split == result.replace('\\n', '').replace('\\t', '') True """ - if kwargs.get('split_account'): + is_investment = kwargs.get('is_investment') + is_transaction = not is_investment + + if is_investment and kwargs.get('inv_split_account'): + content = "L%(inv_split_account)s\n" % kwargs + elif is_investment and self.is_split: + content = "L%(account)s\n" % kwargs + elif is_transaction and kwargs.get('split_account'): content = "S%(split_account)s\n" % kwargs - else: + elif is_transaction: content = "S%(account)s\n" % kwargs + else: + content = '' - if kwargs.get('split_memo'): + if content and kwargs.get('split_memo'): content += "E%(split_memo)s\n" % kwargs - content += "$%(amount)0.2f\n" % kwargs + content += "$%(amount)0.2f\n" % kwargs if content else '' return content def transaction_end(self): @@ -229,45 +265,49 @@ def transaction_end(self): """ return "^\n" - def footer(self, **kwargs): + def footer(self, **kwargs): # pylint: disable=unused-argument """ Gets QIF transaction footer. Returns: - (None): the QIF footer + (str): the QIF footer Examples: >>> QIF().footer() + '' """ - if self.is_split: - return self.transaction_end() + return self.transaction_end() if self.is_split else '' def gen_body(self, data): - for gd in data: - trxn_data = self.transaction_data(gd['trxn']) - account = self.account(gd['trxn']) - group = gd['group'] + """ Generate the QIF body """ + split_account = self.split_account or self.inv_split_account + + for datum in data: + trxn_data = self.transaction_data(datum['trxn']) + account = self.account(datum['trxn']) + grp = datum['group'] - if self.prev_group and self.prev_group != group and self.is_split: + if self.prev_group and self.prev_group != grp and self.is_split: yield self.transaction_end() - if gd['is_main'] and self.prev_account != account: + if datum['is_main'] and self.prev_account != account: yield self.account_start(**trxn_data) - if (self.is_split and gd['is_main']) or not self.is_split: + if (self.is_split and datum['is_main']) or not self.is_split: yield self.transaction(**trxn_data) self.prev_account = account - if (self.is_split and not gd['is_main']) or self.split_account: + if (self.is_split and not datum['is_main']) or split_account: yield self.split_content(**trxn_data) if not self.is_split: yield self.transaction_end() - self.prev_group = group + self.prev_group = grp def gen_groups(self, records, chunksize=None): + """ Generate the QIF groups """ for chnk in chunk(records, chunksize): keyfunc = self.id if self.is_split else self.account - for g in group(chnk, keyfunc): - yield g + for gee in group(chnk, keyfunc): + yield gee diff --git a/csv2ofx/utils.py b/csv2ofx/utils.py index 431369f..cbfcbd4 100644 --- a/csv2ofx/utils.py +++ b/csv2ofx/utils.py @@ -25,9 +25,22 @@ from meza.fntools import get_separators from meza.convert import to_decimal +ACTION_TYPES = { + 'ShrsIn': ('deposit',), + 'ShrsOut': ('withdraw',), + 'Buy': ('buy', 'invest'), + 'Div': ('dividend',), + 'Int': ('interest',), + 'Sell': ('sell',), + 'ReinvDiv': ('reinvest',), + 'StkSplit': ('split',), +} + +TRANSFERABLE = {'Buy', 'Div', 'Int', 'Sell'} + def get_account_type(account, account_types, def_type='n/a'): - """ Detects the account type of a given account + """ Detect the account type of a given account Args: account (str): The account name @@ -53,7 +66,39 @@ def get_account_type(account, account_types, def_type='n/a'): return _type +def get_action(category, transfer=False, def_action='ShrsIn'): + """ Detect the investment action of a given category + + Args: + category (str): The transaction category. + transfer (bool): Is the transaction an account transfer? (default: + False) + def_type (str): The default action. + + Returns: + (str): The resulting action. + + Examples: + >>> get_action('dividend & cap gains') == 'Div' + True + >>> get_action('buy', True) == 'BuyX' + True + """ + _type = def_action + + for key, values in ACTION_TYPES.items(): + if any(v in category.lower() for v in values): + _type = key + break + + if transfer and _type in TRANSFERABLE: + return '%sX' % _type + else: + return _type + + def convert_amount(content): + """ Convert number to a decimal amount """ return to_decimal(content, **get_separators(content)) @@ -103,6 +148,7 @@ def verify_splits(splits, keyfunc): def gen_data(groups): + """ Generate the transaction data """ for group, main_pos, sorted_trxns in groups: for pos, trxn in sorted_trxns: base_data = { diff --git a/data/converted/creditunion.qif b/data/converted/creditunion.qif new file mode 100644 index 0000000..8acff4e --- /dev/null +++ b/data/converted/creditunion.qif @@ -0,0 +1,52 @@ +!Account +NCredit Union +TBank +^ +!Type:Bank +NINV-1 +D02/08/15 +PẰdøłƥh Noƴa +T50000.00 +^ +!Type:Bank +NINV-2 +D02/08/15 +PÓmary Akida +T50000.00 +^ +!Type:Bank +NINV-3 +D02/24/15 +PSadrick Mtel +T70000.00 +^ +!Type:Bank +NINV-4 +D02/28/15 +PHabibœ Said +T65000.00 +^ +!Type:Bank +NINV-5 +D03/04/15 +PNguluko Ómary Yahya +T60000.00 +^ +!Type:Bank +NINV-6 +D03/09/15 +PȢasha Ramadhani +T75000.00 +^ +!Type:Bank +NINV-7 +D03/24/15 +PTchênzema Tchênzema +T45000.00 +^ +!Type:Bank +NINV-8 +D03/25/15 +PƢunȡuƙi Chairman +T50000.00 +^ diff --git a/data/converted/xero.qif b/data/converted/xero.qif index b2c5749..a6c8d0d 100644 --- a/data/converted/xero.qif +++ b/data/converted/xero.qif @@ -22,8 +22,8 @@ TCash NINV-0198 D01/25/15 PBaisikeli -MBicycle LJohn +MBicycle T-113000.00 SIncome $-113000.00 @@ -47,8 +47,8 @@ TCash NINV-0003 D01/24/15 PCharger-baisikeli (distributor price) -MCharger LOffice +MCharger T-30000.00 SIncome $-30000.00 @@ -57,8 +57,8 @@ $-30000.00 NINV-0006 D01/25/15 PCharger-baisikeli (distributor price) -MCharger LJohn +MCharger T-30000.00 SIncome $-30000.00 diff --git a/data/example/investment_example.qif b/data/example/investment_example.qif index a0c2617..9b90bbc 100644 --- a/data/example/investment_example.qif +++ b/data/example/investment_example.qif @@ -2,4 +2,23 @@ NJoint Brokerage Account TInvst ^ -!Type:Invst D8/25/93 NShrsIn Yibm4 I11.260 Q88.81 CX T1,000.00 MOpening ^ D8/25/93 NBuyX Yibm4 I11.030 Q9.066 T100.00 MEst. price as of 8/25/93 L[CHECKING] $100.00 ^ \ No newline at end of file +!Type:Invst +D8/25/93 +NShrsIn +Yibm4 +I11.260 +Q88.81 +CX +T1,000.00 +MOpening +^ +D8/25/93 +NBuyX +Yibm4 +I11.030 +Q9.066 +T100.00 +MEst. price as of 8/25/93 +L[CHECKING] +$100.00 +^ diff --git a/data/test/creditunion.csv b/data/test/creditunion.csv new file mode 100644 index 0000000..f2703ea --- /dev/null +++ b/data/test/creditunion.csv @@ -0,0 +1 @@ +Check Number,Date,Description,Amount,Category,Comments INV-1,2/8/15,Ằdøłƥh Noƴa,50000,Expenses, INV-2,2/8/15,Ómary Akida,50000,Expenses, INV-3,2/24/15,Sadrick Mtel,70000,Expenses, INV-4,2/28/15,Habibœ Said,65000,Expenses, INV-5,3/4/15,Nguluko Ómary Yahya,60000,Expenses, INV-6,3/9/15,Ȣasha Ramadhani,75000,Expenses, INV-7,3/24/15,Tchênzema Tchênzema,45000,Expenses, INV-8,3/25/15,Ƣunȡuƙi Chairman,50000,Expenses, \ No newline at end of file diff --git a/tests/test.py b/tests/test.py index d88d746..f504e84 100755 --- a/tests/test.py +++ b/tests/test.py @@ -111,18 +111,20 @@ def gen_test(raw): else: yield (opts, _in, _out) - MINT_ALT_OPTS = ['-oqs20150613', '-e20150614', '-S Category', '-m mint'] + MINT_ALT_OPTS = ['-oqs20150613', '-e20150614', '-m mint'] SERVER_DATE = '-D 20161031112908' + SPLIT_OPTS = ['-o', '-m split_account', SERVER_DATE] PRE_TESTS = [ (['--help'], [], True), (['-oq'], 'default.csv', 'default.qif'), - (['-oqS Category'], 'default.csv', 'default_w_splits.qif', ), + (['-oq', '-m split_account'], 'default.csv', 'default_w_splits.qif'), (['-oqc Description', '-m xero'], 'xero.csv', 'xero.qif'), - (['-oqS Category', '-m mint'], 'mint.csv', 'mint.qif'), + (['-oq', '-m mint'], 'mint.csv', 'mint.qif'), (MINT_ALT_OPTS, 'mint.csv', 'mint_alt.qif'), (['-oe 20150908', SERVER_DATE], 'default.csv', 'default.ofx'), - (['-oS Category', SERVER_DATE], 'default.csv', 'default_w_splits.ofx'), - (['-oS Category', '-m mint', SERVER_DATE], 'mint.csv', 'mint.ofx'), + (SPLIT_OPTS, 'default.csv', 'default_w_splits.ofx'), + (['-o', '-m mint', SERVER_DATE], 'mint.csv', 'mint.ofx'), + (['-oq', '-m creditunion'], 'creditunion.csv', 'creditunion.qif'), ] main(csv2ofx, gen_test(PRE_TESTS))