Skip to content

Commit

Permalink
Merge pull request #273 from edwagner/invest-statement-enhancements
Browse files Browse the repository at this point in the history
Handle additional types of investment transactions
  • Loading branch information
kedder committed Mar 5, 2024
2 parents cfd9002 + 8c1112c commit 15f8c23
Show file tree
Hide file tree
Showing 4 changed files with 194 additions and 24 deletions.
29 changes: 20 additions & 9 deletions src/ofxstatement/ofx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", {})
Expand Down Expand Up @@ -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"):
Expand All @@ -213,14 +224,19 @@ 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 = (
None # income transactions don't have an envelope element
)

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, {})
Expand Down Expand Up @@ -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)
Expand Down
57 changes: 45 additions & 12 deletions src/ofxstatement/statement.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@
INVEST_TRANSACTION_TYPES = [
"BUYSTOCK",
"BUYDEBT",
"INCOME",
"INVBANKTRAN",
"SELLSTOCK",
"SELLDEBT",
"INCOME",
"TRANSFER",
]

INVEST_TRANSACTION_TYPES_DETAILED = [
Expand All @@ -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 = [
Expand Down Expand Up @@ -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):
Expand Down
47 changes: 44 additions & 3 deletions src/ofxstatement/tests/test_ofx_invest.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
<FEES>1.24000</FEES>
<UNITPRICE>138.28000</UNITPRICE>
<UNITS>3.00000</UNITS>
<TOTAL>-416.08000</TOTAL>
<TOTAL>-416.08</TOTAL>
</INVBUY>
</BUYSTOCK>
<SELLSTOCK>
Expand All @@ -108,7 +108,7 @@
<FEES>0.28000</FEES>
<UNITPRICE>225.63000</UNITPRICE>
<UNITS>-5.00000</UNITS>
<TOTAL>1127.87000</TOTAL>
<TOTAL>1127.87</TOTAL>
</INVSELL>
</SELLSTOCK>
<INCOME>
Expand All @@ -125,8 +125,33 @@
<SUBACCTSEC>OTHER</SUBACCTSEC>
<SUBACCTFUND>OTHER</SUBACCTFUND>
<WITHHOLDING>0.50000</WITHHOLDING>
<TOTAL>0.79000</TOTAL>
<TOTAL>0.79</TOTAL>
</INCOME>
<INVBANKTRAN>
<STMTTRN>
<TRNTYPE>INT</TRNTYPE>
<DTPOSTED>20210102</DTPOSTED>
<TRNAMT>0.45</TRNAMT>
<FITID>6</FITID>
<MEMO>Bank Interest</MEMO>
</STMTTRN>
<SUBACCTFUND>OTHER</SUBACCTFUND>
</INVBANKTRAN>
<TRANSFER>
<INVTRAN>
<FITID>7</FITID>
<DTTRADE>20210103</DTTRADE>
<MEMO>Journaled Shares</MEMO>
</INVTRAN>
<SECID>
<UNIQUEID>MSFT</UNIQUEID>
<UNIQUEIDTYPE>TICKER</UNIQUEIDTYPE>
</SECID>
<SUBACCTSEC>OTHER</SUBACCTSEC>
<SUBACCTFUND>OTHER</SUBACCTFUND>
<UNITPRICE>225.63000</UNITPRICE>
<UNITS>4.00000</UNITS>
</TRANSFER>
</INVTRANLIST>
</INVSTMTRS>
</INVSTMTTRNRS>
Expand Down Expand Up @@ -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)

Expand Down
85 changes: 85 additions & 0 deletions src/ofxstatement/tests/test_statement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

0 comments on commit 15f8c23

Please sign in to comment.