Repository: reubano/csv2ofx (language: python)

Issue:
Allow mappings to specify custom_header
Enables easier parsing of CSV's without headers.

Confirmed all tests are passing (by manually running ./tests/test.py -- for some reason, manage lint hangs for me and manage test throws a AttributeError: module 'collections' has no attribute 'Callable' error for me now).

Once this is merged, I will start another PR that uses this feature in the Schwab Bank mapping (to extract the account_id from the first line of the CSV file).  You can preview the changes by looking at my [schwab-account-id](https://github.com/tstabrawa/csv2ofx/compare/custom_header...tstabrawa:csv2ofx:schwab-account-id) branch.


Discussion / hints:

Fix patch:
diff --git a/csv2ofx/mappings/schwabchecking.py b/csv2ofx/mappings/schwabchecking.py
index 495dd1b..b6565b2 100644
--- a/csv2ofx/mappings/schwabchecking.py
+++ b/csv2ofx/mappings/schwabchecking.py
@@ -2,24 +2,62 @@
     absolute_import, division, print_function, unicode_literals)
 
 from operator import itemgetter
+import re
 
-# 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.
+# Default value if account_id not found in header rows.
+schwab_account_id = "12345"
+
+
+def is_real(transaction):
+    # Filter-out header line & lines that only have one column
+    type = transaction.get('Type')
+    return type and (type != "Type")
+
+
+def schwab_filter(transaction):
+    if is_real(transaction):
+        return True
+
+    # Sanity check header row in CSV matches custom_header
+    line = transaction.get(mapping["custom_header"][0])
+    if line in mapping["custom_header"]:
+        for column in mapping["custom_header"]:
+            found_col = transaction.get(column)
+            if found_col != column:
+                raise ValueError("Header row mismatch (expected: '" + column +
+                        "', found: '" + found_col + "')")
+
+    # Look for account_id in one of the lines being filtered out
+    global schwab_account_id
+    match = re.search(r'^Transactions  for .* account (\.*\d*) as of', line)
+    if match:
+        schwab_account_id = match.group(1)
+    return False
+
+
+def get_account_id(transaction):
+    global schwab_account_id
+    return schwab_account_id
 
-# 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'),
+    # Technically Schwab Bank CSVs do have a header, but we want to extract
+    # the account_id from the line that comes before the header, so we use
+    # custom_header instead of parsing it from the second line of the CSV.
+    'custom_header': ["Date","Type","Check #","Description","Withdrawal (-)",
+        "Deposit (+)","RunningBalance"],
+    'has_header': False,
+    'filter': schwab_filter,
     'is_split': False,
     'bank': 'Charles Schwab Bank, N.A.',
     'bank_id': '121202211',
-    'account_id': '12345',  # Change to your actual account number if desired
+    'account_id': get_account_id,
     'currency': 'USD',
     'account': 'Charles Schwab Checking',
-    'date': lambda tr: tr.get('Date') if tr.get('Type') else "1-1-1970",
+    # 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.
+    'date': lambda tr: tr.get('Date') if is_real(tr) else "1-1-1970",
     'check_num': itemgetter('Check #'),
     'payee': itemgetter('Description'),
     'desc': itemgetter('Description'),
diff --git a/data/converted/schwab-checking-msmoney.ofx b/data/converted/schwab-checking-msmoney.ofx
index cc00bd3..8c46ef5 100644
--- a/data/converted/schwab-checking-msmoney.ofx
+++ b/data/converted/schwab-checking-msmoney.ofx
@@ -29,7 +29,7 @@ NEWFILEUID:NONE
 				<CURDEF>USD</CURDEF>
 				<BANKACCTFROM>
 					<BANKID>121202211</BANKID>
-					<ACCTID>12345</ACCTID>
+					<ACCTID>...123</ACCTID>
 					<ACCTTYPE>CHECKING</ACCTTYPE>
 				</BANKACCTFROM>
 				<BANKTRANLIST>
diff --git a/data/converted/schwab-checking.ofx b/data/converted/schwab-checking.ofx
index 3a258c1..0d3239c 100644
--- a/data/converted/schwab-checking.ofx
+++ b/data/converted/schwab-checking.ofx
@@ -22,7 +22,7 @@ ENCODING:UTF-8
 				<CURDEF>USD</CURDEF>
 				<BANKACCTFROM>
 					<BANKID>121202211</BANKID>
-					<ACCTID>12345</ACCTID>
+					<ACCTID>...123</ACCTID>
 					<ACCTTYPE>CHECKING</ACCTTYPE>
 				</BANKACCTFROM>
 				<BANKTRANLIST>


Test patch:
diff --git a/data/converted/schwab-checking-baltest-case1.ofx b/data/converted/schwab-checking-baltest-case1.ofx
index 3a258c1..0d3239c 100644
--- a/data/converted/schwab-checking-baltest-case1.ofx
+++ b/data/converted/schwab-checking-baltest-case1.ofx
@@ -22,7 +22,7 @@ ENCODING:UTF-8
 				<CURDEF>USD</CURDEF>
 				<BANKACCTFROM>
 					<BANKID>121202211</BANKID>
-					<ACCTID>12345</ACCTID>
+					<ACCTID>...123</ACCTID>
 					<ACCTTYPE>CHECKING</ACCTTYPE>
 				</BANKACCTFROM>
 				<BANKTRANLIST>
diff --git a/data/converted/schwab-checking-baltest-case2.ofx b/data/converted/schwab-checking-baltest-case2.ofx
index fca0bc8..129c277 100644
--- a/data/converted/schwab-checking-baltest-case2.ofx
+++ b/data/converted/schwab-checking-baltest-case2.ofx
@@ -22,7 +22,7 @@ ENCODING:UTF-8
 				<CURDEF>USD</CURDEF>
 				<BANKACCTFROM>
 					<BANKID>121202211</BANKID>
-					<ACCTID>12345</ACCTID>
+					<ACCTID>...123</ACCTID>
 					<ACCTTYPE>CHECKING</ACCTTYPE>
 				</BANKACCTFROM>
 				<BANKTRANLIST>
diff --git a/data/converted/schwab-checking-baltest-case3.ofx b/data/converted/schwab-checking-baltest-case3.ofx
index dc611ef..e57a286 100644
--- a/data/converted/schwab-checking-baltest-case3.ofx
+++ b/data/converted/schwab-checking-baltest-case3.ofx
@@ -22,7 +22,7 @@ ENCODING:UTF-8
 				<CURDEF>USD</CURDEF>
 				<BANKACCTFROM>
 					<BANKID>121202211</BANKID>
-					<ACCTID>12345</ACCTID>
+					<ACCTID>...123</ACCTID>
 					<ACCTTYPE>CHECKING</ACCTTYPE>
 				</BANKACCTFROM>
 				<BANKTRANLIST>
diff --git a/data/converted/schwab-checking-baltest-case4.ofx b/data/converted/schwab-checking-baltest-case4.ofx
index 263faff..cb25db8 100644
--- a/data/converted/schwab-checking-baltest-case4.ofx
+++ b/data/converted/schwab-checking-baltest-case4.ofx
@@ -22,7 +22,7 @@ ENCODING:UTF-8
 				<CURDEF>USD</CURDEF>
 				<BANKACCTFROM>
 					<BANKID>121202211</BANKID>
-					<ACCTID>12345</ACCTID>
+					<ACCTID>...123</ACCTID>
 					<ACCTTYPE>CHECKING</ACCTTYPE>
 				</BANKACCTFROM>
 				<BANKTRANLIST>
diff --git a/data/converted/schwab-checking-baltest-case5.ofx b/data/converted/schwab-checking-baltest-case5.ofx
index 24282de..666498c 100644
--- a/data/converted/schwab-checking-baltest-case5.ofx
+++ b/data/converted/schwab-checking-baltest-case5.ofx
@@ -22,7 +22,7 @@ ENCODING:UTF-8
 				<CURDEF>USD</CURDEF>
 				<BANKACCTFROM>
 					<BANKID>121202211</BANKID>
-					<ACCTID>12345</ACCTID>
+					<ACCTID>...123</ACCTID>
 					<ACCTTYPE>CHECKING</ACCTTYPE>
 				</BANKACCTFROM>
 				<BANKTRANLIST>
diff --git a/data/converted/schwab-checking-baltest-case6.ofx b/data/converted/schwab-checking-baltest-case6.ofx
index e4e27ef..6a650d9 100644
--- a/data/converted/schwab-checking-baltest-case6.ofx
+++ b/data/converted/schwab-checking-baltest-case6.ofx
@@ -22,7 +22,7 @@ ENCODING:UTF-8
 				<CURDEF>USD</CURDEF>
 				<BANKACCTFROM>
 					<BANKID>121202211</BANKID>
-					<ACCTID>12345</ACCTID>
+					<ACCTID>...123</ACCTID>
 					<ACCTTYPE>CHECKING</ACCTTYPE>
 				</BANKACCTFROM>
 				<BANKTRANLIST>
diff --git a/data/converted/schwab-checking-baltest-case7.ofx b/data/converted/schwab-checking-baltest-case7.ofx
index b5bff07..b4f1393 100644
--- a/data/converted/schwab-checking-baltest-case7.ofx
+++ b/data/converted/schwab-checking-baltest-case7.ofx
@@ -22,7 +22,7 @@ ENCODING:UTF-8
 				<CURDEF>USD</CURDEF>
 				<BANKACCTFROM>
 					<BANKID>121202211</BANKID>
-					<ACCTID>12345</ACCTID>
+					<ACCTID>...123</ACCTID>
 					<ACCTTYPE>CHECKING</ACCTTYPE>
 				</BANKACCTFROM>
 				<BANKTRANLIST>