Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle additional types of investment transactions #273

Merged
merged 5 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
Loading