From 8f8008dd5be8f82ca4209a98826d6f6a8212e1cf Mon Sep 17 00:00:00 2001 From: Ed Wagner Date: Sun, 18 Feb 2024 15:20:15 -0600 Subject: [PATCH 1/5] Don't add a blank security to the list For some investment transactions (e.g. INTEREST) there is no security, so don't add a blank that will get rejected by Gnucash. --- src/ofxstatement/ofx.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ofxstatement/ofx.py b/src/ofxstatement/ofx.py index 888c7d5..f746572 100644 --- a/src/ofxstatement/ofx.py +++ b/src/ofxstatement/ofx.py @@ -155,6 +155,8 @@ def buildInvestTransactionList(self) -> None: for security_id in dict.fromkeys( map(lambda x: x.security_id, self.statement.invest_lines) ): + if security_id is None: + continue tb.start("STOCKINFO", {}) tb.start("SECINFO", {}) tb.start("SECID", {}) From 1944606fa584fc84aeebbcf79cddc5b1e64e62f8 Mon Sep 17 00:00:00 2001 From: Ed Wagner Date: Sun, 18 Feb 2024 15:21:23 -0600 Subject: [PATCH 2/5] Decrease precision of total to 2 decimal places Total doesn't need more than 2 decimal places. --- src/ofxstatement/ofx.py | 1 - src/ofxstatement/tests/test_ofx_invest.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/ofxstatement/ofx.py b/src/ofxstatement/ofx.py index f746572..e370374 100644 --- a/src/ofxstatement/ofx.py +++ b/src/ofxstatement/ofx.py @@ -272,7 +272,6 @@ def buildInvestTransaction(self, line: InvestStatementLine) -> None: "TOTAL", line.amount, False, - precision=self.invest_transactions_float_precision, ) if inner_tran_type_tag_name: diff --git a/src/ofxstatement/tests/test_ofx_invest.py b/src/ofxstatement/tests/test_ofx_invest.py index 433a5af..3f6c9a3 100644 --- a/src/ofxstatement/tests/test_ofx_invest.py +++ b/src/ofxstatement/tests/test_ofx_invest.py @@ -88,7 +88,7 @@ 1.24000 138.28000 3.00000 - -416.08000 + -416.08 @@ -108,7 +108,7 @@ 0.28000 225.63000 -5.00000 - 1127.87000 + 1127.87 @@ -125,7 +125,7 @@ OTHER OTHER 0.50000 - 0.79000 + 0.79 From 05e26992206be455ed5bec51dfc9cad8b9714c25 Mon Sep 17 00:00:00 2001 From: Ed Wagner Date: Sun, 18 Feb 2024 15:55:18 -0600 Subject: [PATCH 3/5] Add cap gain income types --- src/ofxstatement/statement.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ofxstatement/statement.py b/src/ofxstatement/statement.py index 8124621..0cc5712 100644 --- a/src/ofxstatement/statement.py +++ b/src/ofxstatement/statement.py @@ -44,6 +44,8 @@ "SELLSHORT", # open short sale "DIV", # only for INCOME "INTEREST", # only for INCOME + "CGLONG", # only for INCOME + "CGSHORT", # only for INCOME ] ACCOUNT_TYPE = [ From 78cba7e998dd2ea0e05b3f814fb51806d85cba2c Mon Sep 17 00:00:00 2001 From: Ed Wagner Date: Sun, 3 Mar 2024 18:51:47 -0600 Subject: [PATCH 4/5] Handle INVBANKTRAN For INTEREST, XFER, and other cash transactions that are not associated with a security, INVBANKTRAN is the appropriate transaction type. The body is like a bank transaction (STMTTRN), not an INVTRAN. --- src/ofxstatement/ofx.py | 9 +++ src/ofxstatement/statement.py | 46 ++++++++++++---- src/ofxstatement/tests/test_ofx_invest.py | 17 ++++++ src/ofxstatement/tests/test_statement.py | 67 +++++++++++++++++++++++ 4 files changed, 129 insertions(+), 10 deletions(-) diff --git a/src/ofxstatement/ofx.py b/src/ofxstatement/ofx.py index e370374..38b7bb2 100644 --- a/src/ofxstatement/ofx.py +++ b/src/ofxstatement/ofx.py @@ -207,6 +207,15 @@ def buildInvestTransaction(self, line: InvestStatementLine) -> None: tb = self.tb + if line.trntype == "INVBANKTRAN": + tb.start(line.trntype, {}) + bankTran = StatementLine(line.id, line.date, line.memo, line.amount) + bankTran.trntype = line.trntype_detailed + self.buildBankTransaction(bankTran) + self.buildText("SUBACCTFUND", "OTHER") + tb.end(line.trntype) + return + tran_type_detailed_tag_name = None inner_tran_type_tag_name = None if line.trntype.startswith("BUY"): diff --git a/src/ofxstatement/statement.py b/src/ofxstatement/statement.py index 0cc5712..e79044e 100644 --- a/src/ofxstatement/statement.py +++ b/src/ofxstatement/statement.py @@ -35,6 +35,7 @@ "SELLSTOCK", "SELLDEBT", "INCOME", + "INVBANKTRAN", ] INVEST_TRANSACTION_TYPES_DETAILED = [ @@ -48,6 +49,15 @@ "CGSHORT", # only for INCOME ] +INVBANKTRAN_TYPES_DETAILED = [ + "INT", + "XFER", + "DEBIT", + "CREDIT", + "SRVCHG", + "OTHER", +] + ACCOUNT_TYPE = [ "CHECKING", # Checking "SAVINGS", # Savings @@ -277,19 +287,35 @@ def assert_valid(self) -> None: INVEST_TRANSACTION_TYPES, ) - assert ( - self.trntype_detailed in INVEST_TRANSACTION_TYPES_DETAILED - ), "trntype_detailed %s is not valid, must be one of %s" % ( - self.trntype_detailed, - INVEST_TRANSACTION_TYPES_DETAILED, - ) + if self.trntype == "INVBANKTRAN": + assert self.trntype_detailed in INVBANKTRAN_TYPES_DETAILED, ( + "trntype_detailed %s is not valid for INVBANKTRAN, must be one of %s" + % ( + self.trntype_detailed, + INVBANKTRAN_TYPES_DETAILED, + ) + ) + else: + assert ( + self.trntype_detailed in INVEST_TRANSACTION_TYPES_DETAILED + ), "trntype_detailed %s is not valid, must be one of %s" % ( + self.trntype_detailed, + INVEST_TRANSACTION_TYPES_DETAILED, + ) assert self.id - assert self.security_id + assert self.date assert self.amount - - assert self.trntype == "INCOME" or self.units - assert self.trntype == "INCOME" or self.unit_price + assert self.trntype == "INVBANKTRAN" or self.security_id + + if self.trntype == "INVBANKTRAN": + pass + elif self.trntype == "INCOME": + assert self.security_id + else: + assert self.security_id + assert self.units + assert self.unit_price class BankAccount(Printable): diff --git a/src/ofxstatement/tests/test_ofx_invest.py b/src/ofxstatement/tests/test_ofx_invest.py index 3f6c9a3..bda3329 100644 --- a/src/ofxstatement/tests/test_ofx_invest.py +++ b/src/ofxstatement/tests/test_ofx_invest.py @@ -127,6 +127,16 @@ 0.50000 0.79 + + + INT + 20210102 + 0.45 + 6 + Bank Interest + + OTHER + @@ -190,6 +200,13 @@ def test_ofxWriter(self) -> None: invest_line.assert_valid() statement.invest_lines.append(invest_line) + invest_line = InvestStatementLine( + "6", datetime(2021, 1, 2), "Bank Interest", "INVBANKTRAN", "INT" + ) + invest_line.amount = Decimal("0.45") + invest_line.assert_valid() + statement.invest_lines.append(invest_line) + # Create writer: writer = ofx.OfxWriter(statement) diff --git a/src/ofxstatement/tests/test_statement.py b/src/ofxstatement/tests/test_statement.py index 730bc94..7cea900 100644 --- a/src/ofxstatement/tests/test_statement.py +++ b/src/ofxstatement/tests/test_statement.py @@ -50,3 +50,70 @@ def test_generate_unique_transaction_id(self) -> None: self.assertTrue(tid2.endswith("-1")) self.assertEqual(len(txnids), 2) + + def test_invbank_line_validation(self) -> None: + line = statement.InvestStatementLine("id", datetime(2020, 3, 25)) + line.trntype = "INVBANKTRAN" + line.trntype_detailed = "INT" + line.amount = Decimal(1) + line.assert_valid() + with self.assertRaises(AssertionError): + line.amount = None + line.assert_valid() + line.amount = Decimal(1) + with self.assertRaises(AssertionError): + line.trntype_detailed = "BLAH" + line.assert_valid() + + def test_income_line_validation(self) -> None: + line = statement.InvestStatementLine("id", datetime(2020, 3, 25)) + line.trntype = "INCOME" + line.trntype_detailed = "INTEREST" + line.amount = Decimal(1) + line.security_id = "AAPL" + line.assert_valid() + with self.assertRaises(AssertionError): + line.amount = None + line.assert_valid() + line.amount = Decimal(1) + with self.assertRaises(AssertionError): + line.trntype_detailed = "BLAH" + line.assert_valid() + line.trntype_detailed = "INTEREST" + with self.assertRaises(AssertionError): + line.security_id = None + line.assert_valid() + + def test_buy_line_validation(self) -> None: + line = statement.InvestStatementLine("id", datetime(2020, 3, 25)) + line.trntype = "BUYSTOCK" + line.trntype_detailed = "BUY" + line.amount = Decimal(1) + line.security_id = "AAPL" + line.units = Decimal(3) + line.unit_price = Decimal(1.1) + line.assert_valid() + + with self.assertRaises(AssertionError): + line.amount = None + line.assert_valid() + line.amount = Decimal(1) + + with self.assertRaises(AssertionError): + line.trntype_detailed = "BLAH" + line.assert_valid() + line.trntype_detailed = "INTEREST" + + with self.assertRaises(AssertionError): + line.security_id = None + line.assert_valid() + line.security_id = "AAPL" + + with self.assertRaises(AssertionError): + line.units = None + line.assert_valid() + line.units = Decimal(3) + + with self.assertRaises(AssertionError): + line.unit_price = None + line.assert_valid() From 8c1112c2a31bad3726360618b02e8f9160d4cc65 Mon Sep 17 00:00:00 2001 From: Ed Wagner Date: Sun, 3 Mar 2024 19:00:46 -0600 Subject: [PATCH 5/5] Handle TRANSFER type For share transfers, TRANSFER is the appropriate type with no trntype_detailed, no amount and an optional unit_price. --- src/ofxstatement/ofx.py | 17 ++++++++-------- src/ofxstatement/statement.py | 13 ++++++++---- src/ofxstatement/tests/test_ofx_invest.py | 24 +++++++++++++++++++++++ src/ofxstatement/tests/test_statement.py | 18 +++++++++++++++++ 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/ofxstatement/ofx.py b/src/ofxstatement/ofx.py index 38b7bb2..8b034c6 100644 --- a/src/ofxstatement/ofx.py +++ b/src/ofxstatement/ofx.py @@ -201,8 +201,8 @@ def buildInvestTransactionList(self) -> None: tb.end("INVSTMTMSGSRSV1") def buildInvestTransaction(self, line: InvestStatementLine) -> None: - # invest transactions must always have trntype and trntype_detailed - if line.trntype is None or line.trntype_detailed is None: + # invest transactions must always have trntype + if line.trntype is None: return tb = self.tb @@ -224,6 +224,10 @@ def buildInvestTransaction(self, line: InvestStatementLine) -> None: elif line.trntype.startswith("SELL"): tran_type_detailed_tag_name = "SELLTYPE" inner_tran_type_tag_name = "INVSELL" + elif line.trntype == "TRANSFER": + # Transfer transactions don't have details or an envelope + tran_type_detailed_tag_name = None + inner_tran_type_tag_name = None else: tran_type_detailed_tag_name = "INCOMETYPE" inner_tran_type_tag_name = ( @@ -231,7 +235,8 @@ def buildInvestTransaction(self, line: InvestStatementLine) -> None: ) tb.start(line.trntype, {}) - self.buildText(tran_type_detailed_tag_name, line.trntype_detailed, False) + if tran_type_detailed_tag_name: + self.buildText(tran_type_detailed_tag_name, line.trntype_detailed, False) if inner_tran_type_tag_name: tb.start(inner_tran_type_tag_name, {}) @@ -277,11 +282,7 @@ def buildInvestTransaction(self, line: InvestStatementLine) -> None: precision=self.invest_transactions_float_precision, ) - self.buildAmount( - "TOTAL", - line.amount, - False, - ) + self.buildAmount("TOTAL", line.amount) if inner_tran_type_tag_name: tb.end(inner_tran_type_tag_name) diff --git a/src/ofxstatement/statement.py b/src/ofxstatement/statement.py index e79044e..859b8b7 100644 --- a/src/ofxstatement/statement.py +++ b/src/ofxstatement/statement.py @@ -32,10 +32,11 @@ INVEST_TRANSACTION_TYPES = [ "BUYSTOCK", "BUYDEBT", - "SELLSTOCK", - "SELLDEBT", "INCOME", "INVBANKTRAN", + "SELLSTOCK", + "SELLDEBT", + "TRANSFER", ] INVEST_TRANSACTION_TYPES_DETAILED = [ @@ -295,6 +296,10 @@ def assert_valid(self) -> None: INVBANKTRAN_TYPES_DETAILED, ) ) + elif self.trntype == "TRANSFER": + assert ( + self.trntype_detailed is None + ), f"trntype_detailed '{self.trntype_detailed}' should be empty for TRANSFERS" else: assert ( self.trntype_detailed in INVEST_TRANSACTION_TYPES_DETAILED @@ -305,7 +310,7 @@ def assert_valid(self) -> None: assert self.id assert self.date - assert self.amount + assert self.trntype == "TRANSFER" or self.amount assert self.trntype == "INVBANKTRAN" or self.security_id if self.trntype == "INVBANKTRAN": @@ -315,7 +320,7 @@ def assert_valid(self) -> None: else: assert self.security_id assert self.units - assert self.unit_price + assert self.trntype == "TRANSFER" or self.unit_price class BankAccount(Printable): diff --git a/src/ofxstatement/tests/test_ofx_invest.py b/src/ofxstatement/tests/test_ofx_invest.py index bda3329..82ba15b 100644 --- a/src/ofxstatement/tests/test_ofx_invest.py +++ b/src/ofxstatement/tests/test_ofx_invest.py @@ -137,6 +137,21 @@ OTHER + + + 7 + 20210103 + Journaled Shares + + + MSFT + TICKER + + OTHER + OTHER + 225.63000 + 4.00000 + @@ -207,6 +222,15 @@ def test_ofxWriter(self) -> None: invest_line.assert_valid() statement.invest_lines.append(invest_line) + invest_line = InvestStatementLine( + "7", datetime(2021, 1, 3), "Journaled Shares", "TRANSFER" + ) + invest_line.security_id = "MSFT" + invest_line.units = Decimal("4") + invest_line.unit_price = Decimal("225.63") + invest_line.assert_valid() + statement.invest_lines.append(invest_line) + # Create writer: writer = ofx.OfxWriter(statement) diff --git a/src/ofxstatement/tests/test_statement.py b/src/ofxstatement/tests/test_statement.py index 7cea900..cb63320 100644 --- a/src/ofxstatement/tests/test_statement.py +++ b/src/ofxstatement/tests/test_statement.py @@ -51,6 +51,24 @@ def test_generate_unique_transaction_id(self) -> None: self.assertTrue(tid2.endswith("-1")) self.assertEqual(len(txnids), 2) + def test_transfer_line_validation(self) -> None: + line = statement.InvestStatementLine("id", datetime(2020, 3, 25)) + line.trntype = "TRANSFER" + line.security_id = "ABC" + line.units = Decimal(2) + line.assert_valid() + with self.assertRaises(AssertionError): + line.security_id = None + line.assert_valid() + line.security_id = "ABC" + with self.assertRaises(AssertionError): + line.units = None + line.assert_valid() + line.units = Decimal(2) + with self.assertRaises(AssertionError): + line.trntype_detailed = "DETAIL" + line.assert_valid() + def test_invbank_line_validation(self) -> None: line = statement.InvestStatementLine("id", datetime(2020, 3, 25)) line.trntype = "INVBANKTRAN"