-
-
Notifications
You must be signed in to change notification settings - Fork 73
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Refactor parsing and formatting (#122)
* Update .po files to replace event.py's "DIVIDENDS" type with Portfolio Performance's "DIVIDEND" type * Buggy commit to refactor the event parsing and csf file formatting to include taxes, fees, saveback correctly 1. Removes card_failed_transaction type due to it not being relevant for PP 2. Adds INCOMING_TRANSFER_DELEGATION and from issue #116 (card_successful_oct, TAX_REFUND, benefits_saveback_execution) to event.py 3. Introduces enums and dataclasses in event.py to a. structure and facilitate type matching b. reduce Event object's memory size c. differentiate between pp_types and types that require further csv formating. 4. Introduces hacky tax and fee parsers in event.py 5. Introduces event_formatter.py to generate multiple csv lines from certain events (saveback, taxed income, transactions with fees) Requires extensive testing (functionality + python version) * Fixes various parsing and formatting errors. What remains: 1. Check if still compatible with python 3.8 2. Add Fees field in csv file to avoid having to generate a new Fee-type line for each fee occurence 3. Improve parsers with more logs * Clarify certain comments; minor fixes; simplify tax parsing to only include tax value and no further info (KapESt, Soli, Kirche) * Further simplify tax parsing * Restores language translation Adds a cli argument to dl_docs and export_transactions to chronologically sort the exported csv transactions Removes unused imports Various small modifications * Adds taxes and fees columns to the csv file instead of generating stand-alone taxes and fees events * 1. Changes fees and taxes sign to negative 2. Maps card_failed_transaction events with executed status to REMOVALs 3. Renames UnprocessedEventType to ConditionalEventType for more clarity
- Loading branch information
Showing
20 changed files
with
382 additions
and
145 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,100 +1,253 @@ | ||
from dataclasses import dataclass | ||
from datetime import datetime | ||
from enum import auto, Enum | ||
import re | ||
|
||
tr_eventType_to_pp_type = { | ||
'INCOMING_TRANSFER': 'DEPOSIT', | ||
'PAYMENT_INBOUND': 'DEPOSIT', | ||
'PAYMENT_INBOUND_GOOGLE_PAY': 'DEPOSIT', | ||
'PAYMENT_INBOUND_SEPA_DIRECT_DEBIT': 'DEPOSIT', | ||
'card_refund': 'DEPOSIT', | ||
|
||
'CREDIT': 'DIVIDENDS', | ||
'ssp_corporate_action_invoice_cash': 'DIVIDENDS', | ||
|
||
'INTEREST_PAYOUT_CREATED': 'INTEREST', | ||
|
||
'OUTGOING_TRANSFER_DELEGATION': 'REMOVAL', | ||
'PAYMENT_OUTBOUND': 'REMOVAL', | ||
'card_failed_transaction': 'REMOVAL', | ||
'card_order_billed': 'REMOVAL', | ||
'card_successful_atm_withdrawal': 'REMOVAL', | ||
'card_successful_transaction': 'REMOVAL', | ||
|
||
'ORDER_EXECUTED': 'TRADE_INVOICE', | ||
'SAVINGS_PLAN_EXECUTED': 'TRADE_INVOICE', | ||
'SAVINGS_PLAN_INVOICE_CREATED': 'TRADE_INVOICE', | ||
'TRADE_INVOICE': 'TRADE_INVOICE' | ||
from typing import Any, Dict, Optional, Tuple, Union | ||
|
||
|
||
class ConditionalEventType(Enum): | ||
"""Events that conditionally map to None or one/multiple PPEventType events""" | ||
|
||
FAILED_CARD_TRANSACTION = auto() | ||
SAVEBACK = auto() | ||
TRADE_INVOICE = auto() | ||
|
||
|
||
class PPEventType(Enum): | ||
"""PP Event Types""" | ||
|
||
BUY = "BUY" | ||
DEPOSIT = "DEPOSIT" | ||
DIVIDEND = "DIVIDEND" | ||
FEES = "FEES" # Currently not mapped to | ||
FEES_REFUND = "FEES_REFUND" # Currently not mapped to | ||
INTEREST = "INTEREST" | ||
INTEREST_CHARGE = "INTEREST_CHARGE" # Currently not mapped to | ||
REMOVAL = "REMOVAL" | ||
SELL = "SELL" | ||
TAXES = "TAXES" # Currently not mapped to | ||
TAX_REFUND = "TAX_REFUND" | ||
TRANSFER_IN = "TRANSFER_IN" # Currently not mapped to | ||
TRANSFER_OUT = "TRANSFER_OUT" # Currently not mapped to | ||
|
||
|
||
class EventType(Enum): | ||
PP_EVENT_TYPE = PPEventType | ||
CONDITIONAL_EVENT_TYPE = ConditionalEventType | ||
|
||
|
||
tr_event_type_mapping = { | ||
# Deposits | ||
"INCOMING_TRANSFER": PPEventType.DEPOSIT, | ||
"INCOMING_TRANSFER_DELEGATION": PPEventType.DEPOSIT, | ||
"PAYMENT_INBOUND": PPEventType.DEPOSIT, | ||
"PAYMENT_INBOUND_GOOGLE_PAY": PPEventType.DEPOSIT, | ||
"PAYMENT_INBOUND_SEPA_DIRECT_DEBIT": PPEventType.DEPOSIT, | ||
"card_refund": PPEventType.DEPOSIT, | ||
"card_successful_oct": PPEventType.DEPOSIT, | ||
# Dividends | ||
"CREDIT": PPEventType.DIVIDEND, | ||
"ssp_corporate_action_invoice_cash": PPEventType.DIVIDEND, | ||
# Failed card transactions | ||
"card_failed_transaction": ConditionalEventType.FAILED_CARD_TRANSACTION, | ||
# Interests | ||
"INTEREST_PAYOUT": PPEventType.INTEREST, | ||
"INTEREST_PAYOUT_CREATED": PPEventType.INTEREST, | ||
# Removals | ||
"OUTGOING_TRANSFER_DELEGATION": PPEventType.REMOVAL, | ||
"PAYMENT_OUTBOUND": PPEventType.REMOVAL, | ||
"card_order_billed": PPEventType.REMOVAL, | ||
"card_successful_atm_withdrawal": PPEventType.REMOVAL, | ||
"card_successful_transaction": PPEventType.REMOVAL, | ||
# Saveback | ||
"benefits_saveback_execution": ConditionalEventType.SAVEBACK, | ||
# Tax refunds | ||
"TAX_REFUND": PPEventType.TAX_REFUND, | ||
# Trade invoices | ||
"ORDER_EXECUTED": ConditionalEventType.TRADE_INVOICE, | ||
"SAVINGS_PLAN_EXECUTED": ConditionalEventType.TRADE_INVOICE, | ||
"SAVINGS_PLAN_INVOICE_CREATED": ConditionalEventType.TRADE_INVOICE, | ||
"TRADE_INVOICE": ConditionalEventType.TRADE_INVOICE, | ||
} | ||
|
||
|
||
@dataclass | ||
class Event: | ||
def __init__(self, event_json): | ||
self.event = event_json | ||
self.shares = "" | ||
self.isin = "" | ||
|
||
self.pp_type = tr_eventType_to_pp_type.get(self.event["eventType"], "") | ||
self.body = self.event.get("body", "") | ||
self.process_event() | ||
|
||
@property | ||
def date(self): | ||
dateTime = datetime.fromisoformat(self.event["timestamp"][:19]) | ||
return dateTime.strftime("%Y-%m-%d") | ||
|
||
@property | ||
def is_pp_relevant(self): | ||
if self.event["eventType"] == "card_failed_transaction": | ||
if self.event["status"] == "CANCELED": | ||
return False | ||
return self.pp_type != "" | ||
|
||
@property | ||
def amount(self): | ||
return str(self.event["amount"]["value"]) | ||
|
||
@property | ||
def note(self): | ||
if self.event["eventType"].find("card_") == 0: | ||
return self.event["eventType"] | ||
else: | ||
return "" | ||
|
||
@property | ||
def title(self): | ||
return self.event["title"] | ||
|
||
def determine_pp_type(self): | ||
if self.pp_type == "TRADE_INVOICE": | ||
if self.event["amount"]["value"] < 0: | ||
self.pp_type = "BUY" | ||
else: | ||
self.pp_type = "SELL" | ||
|
||
def determine_shares(self): | ||
if self.pp_type == "TRADE_INVOICE": | ||
sections = self.event.get("details", {}).get("sections", [{}]) | ||
for section in sections: | ||
if section.get("title") == "Transaktion": | ||
amount = section.get("data", [{}])[0]["detail"]["text"] | ||
amount = re.sub("[^\,\d-]", "", amount) | ||
self.shares = amount.replace(",", ".") | ||
|
||
def determine_isin(self): | ||
if self.pp_type in ("DIVIDENDS", "TRADE_INVOICE"): | ||
sections = self.event.get("details", {}).get("sections", [{}]) | ||
self.isin = self.event.get("icon", "") | ||
self.isin = self.isin[self.isin.find("/") + 1 :] | ||
self.isin = self.isin[: self.isin.find("/")] | ||
isin2 = self.isin | ||
for section in sections: | ||
action = section.get("action", None) | ||
if action and action.get("type", {}) == "instrumentDetail": | ||
isin2 = section.get("action", {}).get("payload") | ||
break | ||
if self.isin != isin2: | ||
self.isin = isin2 | ||
|
||
def process_event(self): | ||
self.determine_shares() | ||
self.determine_isin() | ||
self.determine_pp_type() | ||
date: datetime | ||
title: str | ||
event_type: Optional[EventType] | ||
fees: Optional[float] | ||
isin: Optional[str] | ||
note: Optional[str] | ||
shares: Optional[float] | ||
taxes: Optional[float] | ||
value: Optional[float] | ||
|
||
@classmethod | ||
def from_dict(cls, event_dict: Dict[Any, Any]): | ||
"""Deserializes the event dictionary into an Event object | ||
Args: | ||
event_dict (json): _description_ | ||
Returns: | ||
Event: Event object | ||
""" | ||
date: datetime = datetime.fromisoformat(event_dict["timestamp"][:19]) | ||
event_type: Optional[EventType] = cls._parse_type(event_dict) | ||
title: str = event_dict["title"] | ||
value: Optional[float] = ( | ||
v | ||
if (v := event_dict.get("amount", {}).get("value", None)) is not None | ||
and v != 0.0 | ||
else None | ||
) | ||
fees, isin, note, shares, taxes = cls._parse_type_dependent_params( | ||
event_type, event_dict | ||
) | ||
return cls(date, title, event_type, fees, isin, note, shares, taxes, value) | ||
|
||
@staticmethod | ||
def _parse_type(event_dict: Dict[Any, Any]) -> Optional[EventType]: | ||
event_type: Optional[EventType] = tr_event_type_mapping.get( | ||
event_dict.get("eventType", ""), None | ||
) | ||
if event_type == ConditionalEventType.FAILED_CARD_TRANSACTION: | ||
event_type = ( | ||
PPEventType.REMOVAL | ||
if event_dict.get("status", "").lower() == "executed" | ||
else None | ||
) | ||
return event_type | ||
|
||
@classmethod | ||
def _parse_type_dependent_params( | ||
cls, event_type: EventType, event_dict: Dict[Any, Any] | ||
) -> Tuple[Optional[Union[str, float]]]: | ||
"""Parses the fees, isin, note, shares and taxes fields | ||
Args: | ||
event_type (EventType): _description_ | ||
event_dict (Dict[Any, Any]): _description_ | ||
Returns: | ||
Tuple[Optional[Union[str, float]]]]: fees, isin, note, shares, taxes | ||
""" | ||
isin, shares, taxes, note, fees = (None,) * 5 | ||
|
||
if event_type is PPEventType.DIVIDEND: | ||
isin = cls._parse_isin(event_dict) | ||
taxes = cls._parse_taxes(event_dict) | ||
|
||
elif isinstance(event_type, ConditionalEventType): | ||
isin = cls._parse_isin(event_dict) | ||
shares, fees = cls._parse_shares_and_fees(event_dict) | ||
taxes = cls._parse_taxes(event_dict) | ||
|
||
elif event_type is PPEventType.INTEREST: | ||
taxes = cls._parse_taxes(event_dict) | ||
|
||
elif event_type in [PPEventType.DEPOSIT, PPEventType.REMOVAL]: | ||
note = cls._parse_card_note(event_dict) | ||
|
||
return fees, isin, note, shares, taxes | ||
|
||
@staticmethod | ||
def _parse_isin(event_dict: Dict[Any, Any]) -> str: | ||
"""Parses the isin | ||
Args: | ||
event_dict (Dict[Any, Any]): _description_ | ||
Returns: | ||
str: isin | ||
""" | ||
sections = event_dict.get("details", {}).get("sections", [{}]) | ||
isin = event_dict.get("icon", "") | ||
isin = isin[isin.find("/") + 1 :] | ||
isin = isin[: isin.find("/")] | ||
isin2 = isin | ||
for section in sections: | ||
action = section.get("action", None) | ||
if action and action.get("type", {}) == "instrumentDetail": | ||
isin2 = section.get("action", {}).get("payload") | ||
break | ||
if isin != isin2: | ||
isin = isin2 | ||
return isin | ||
|
||
@staticmethod | ||
def _parse_shares_and_fees(event_dict: Dict[Any, Any]) -> Tuple[Optional[float]]: | ||
"""Parses the amount of shares and the applicable fees | ||
Args: | ||
event_dict (Dict[Any, Any]): _description_ | ||
Returns: | ||
Tuple[Optional[float]]: [shares, fees] | ||
""" | ||
return_vals = {} | ||
sections = event_dict.get("details", {}).get("sections", [{}]) | ||
for section in sections: | ||
if section.get("title") == "Transaktion": | ||
data = section["data"] | ||
shares_dicts = list( | ||
filter(lambda x: x["title"] in ["Aktien", "Anteile"], data) | ||
) | ||
fees_dicts = list(filter(lambda x: x["title"] == "Gebühr", data)) | ||
titles = ["shares"] * len(shares_dicts) + ["fees"] * len(fees_dicts) | ||
for key, elem_dict in zip(titles, shares_dicts + fees_dicts): | ||
elem_unparsed = elem_dict.get("detail", {}).get("text", "") | ||
elem_parsed = re.sub("[^\,\.\d-]", "", elem_unparsed).replace( | ||
",", "." | ||
) | ||
return_vals[key] = ( | ||
None | ||
if elem_parsed == "" or float(elem_parsed) == 0.0 | ||
else float(elem_parsed) | ||
) | ||
return return_vals["shares"], return_vals["fees"] | ||
|
||
@staticmethod | ||
def _parse_taxes(event_dict: Dict[Any, Any]) -> Tuple[Optional[float]]: | ||
"""Parses the levied taxes | ||
Args: | ||
event_dict (Dict[Any, Any]): _description_ | ||
Returns: | ||
Tuple[Optional[float]]: [taxes] | ||
""" | ||
# taxes keywords | ||
taxes_keys = {"Steuer", "Steuern"} | ||
# Gather all section dicts | ||
sections = event_dict.get("details", {}).get("sections", [{}]) | ||
# Gather all dicts pertaining to transactions | ||
transaction_dicts = filter( | ||
lambda x: x["title"] in {"Transaktion", "Geschäft"}, sections | ||
) | ||
for transaction_dict in transaction_dicts: | ||
# Filter for taxes dicts | ||
data = transaction_dict.get("data", [{}]) | ||
taxes_dicts = filter(lambda x: x["title"] in taxes_keys, data) | ||
# Iterate over dicts containing tax information and parse each one | ||
for taxes_dict in taxes_dicts: | ||
unparsed_taxes_val = taxes_dict.get("detail", {}).get("text", "") | ||
parsed_taxes_val = re.sub("[^\,\.\d-]", "", unparsed_taxes_val).replace( | ||
",", "." | ||
) | ||
if parsed_taxes_val != "" and float(parsed_taxes_val) != 0.0: | ||
return float(parsed_taxes_val) | ||
|
||
@staticmethod | ||
def _parse_card_note(event_dict: Dict[Any, Any]) -> Optional[str]: | ||
"""Parses the note associated with card transactions | ||
Args: | ||
event_dict (Dict[Any, Any]): _description_ | ||
Returns: | ||
Optional[str]: note | ||
""" | ||
if event_dict.get("eventType", "").startswith("card_"): | ||
return event_dict["eventType"] |
Oops, something went wrong.