diff --git a/src/ofxstatement/ofx.py b/src/ofxstatement/ofx.py index 888c7d5..8b034c6 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", {}) @@ -199,12 +201,21 @@ 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 + 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"): @@ -213,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 = ( @@ -220,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, {}) @@ -266,12 +282,7 @@ def buildInvestTransaction(self, line: InvestStatementLine) -> None: precision=self.invest_transactions_float_precision, ) - self.buildAmount( - "TOTAL", - line.amount, - False, - precision=self.invest_transactions_float_precision, - ) + 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 8124621..859b8b7 100644 --- a/src/ofxstatement/statement.py +++ b/src/ofxstatement/statement.py @@ -32,9 +32,11 @@ INVEST_TRANSACTION_TYPES = [ "BUYSTOCK", "BUYDEBT", + "INCOME", + "INVBANKTRAN", "SELLSTOCK", "SELLDEBT", - "INCOME", + "TRANSFER", ] INVEST_TRANSACTION_TYPES_DETAILED = [ @@ -44,6 +46,17 @@ "SELLSHORT", # open short sale "DIV", # only for INCOME "INTEREST", # only for INCOME + "CGLONG", # only for INCOME + "CGSHORT", # only for INCOME +] + +INVBANKTRAN_TYPES_DETAILED = [ + "INT", + "XFER", + "DEBIT", + "CREDIT", + "SRVCHG", + "OTHER", ] ACCOUNT_TYPE = [ @@ -275,19 +288,39 @@ 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, + ) + ) + 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 + ), "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.amount - - assert self.trntype == "INCOME" or self.units - assert self.trntype == "INCOME" or self.unit_price + assert self.date + assert self.trntype == "TRANSFER" or self.amount + 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.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 433a5af..82ba15b 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,8 +125,33 @@ OTHER OTHER 0.50000 - 0.79000 + 0.79 + + + INT + 20210102 + 0.45 + 6 + Bank Interest + + OTHER + + + + 7 + 20210103 + Journaled Shares + + + MSFT + TICKER + + OTHER + OTHER + 225.63000 + 4.00000 + @@ -190,6 +215,22 @@ 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) + + 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 730bc94..cb63320 100644 --- a/src/ofxstatement/tests/test_statement.py +++ b/src/ofxstatement/tests/test_statement.py @@ -50,3 +50,88 @@ 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" + 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()