From 90c3af2a91c591c4c3387bf4ab0ef5bd6e4e316c Mon Sep 17 00:00:00 2001 From: Benjamin Dornel Date: Sun, 8 Sep 2024 23:57:48 +0800 Subject: [PATCH] feat(banks): add support for zurcherkantonalbank --- src/monopoly/banks/__init__.py | 2 ++ src/monopoly/banks/zkb/__init__.py | 3 ++ src/monopoly/banks/zkb/zkb.py | 34 ++++++++++++++++++++++ src/monopoly/constants/date.py | 3 ++ src/monopoly/constants/statement.py | 8 +++++ src/monopoly/pipeline.py | 3 +- src/monopoly/statements/debit_statement.py | 15 ++++++++-- src/monopoly/statements/transaction.py | 6 ++-- 8 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 src/monopoly/banks/zkb/__init__.py create mode 100644 src/monopoly/banks/zkb/zkb.py diff --git a/src/monopoly/banks/__init__.py b/src/monopoly/banks/__init__.py index 068862ab..cb9fb8d6 100644 --- a/src/monopoly/banks/__init__.py +++ b/src/monopoly/banks/__init__.py @@ -11,6 +11,7 @@ from .ocbc import Ocbc from .standard_chartered import StandardChartered from .uob import Uob +from .zkb import ZurcherKantonalBank banks: list[Type["BankBase"]] = [ Citibank, @@ -21,6 +22,7 @@ Ocbc, StandardChartered, Uob, + ZurcherKantonalBank, ] logger = logging.getLogger(__name__) diff --git a/src/monopoly/banks/zkb/__init__.py b/src/monopoly/banks/zkb/__init__.py new file mode 100644 index 00000000..a1e4286d --- /dev/null +++ b/src/monopoly/banks/zkb/__init__.py @@ -0,0 +1,3 @@ +from .zkb import ZurcherKantonalBank + +__all__ = ["ZurcherKantonalBank"] diff --git a/src/monopoly/banks/zkb/zkb.py b/src/monopoly/banks/zkb/zkb.py new file mode 100644 index 00000000..86ce9df4 --- /dev/null +++ b/src/monopoly/banks/zkb/zkb.py @@ -0,0 +1,34 @@ +import logging +from re import compile as regex + +from monopoly.config import StatementConfig +from monopoly.constants import BankNames, DebitTransactionPatterns, EntryType +from monopoly.constants.date import ISO8601 +from monopoly.identifiers import MetadataIdentifier + +from ..base import BankBase + +logger = logging.getLogger(__name__) + + +class ZurcherKantonalBank(BankBase): + debit_config = StatementConfig( + statement_type=EntryType.DEBIT, + bank_name=BankNames.ZKB, + statement_date_pattern=regex(rf"Balance as of: ({ISO8601.DD_MM_YYYY})"), + header_pattern=regex(r"(Date.*Booking text.*Debit CHF.*Credit CHF)"), + transaction_pattern=DebitTransactionPatterns.ZKB, + multiline_transactions=True, + ) + + identifiers = [ + [ + MetadataIdentifier( + format="PDF 1.7", + title="SLK_Vermoegensinfo_Group", + creator="Designer", + producer="PDFlib+PDI", + ) + ] + ] + statement_configs = [debit_config] diff --git a/src/monopoly/constants/date.py b/src/monopoly/constants/date.py index 7c266d8f..9df7d112 100644 --- a/src/monopoly/constants/date.py +++ b/src/monopoly/constants/date.py @@ -27,6 +27,9 @@ class DateFormats(StrEnum): class ISO8601(RegexEnum): DD_MM = rf"\b({DateFormats.DD}[\/\-\s]{DateFormats.MM})" DD_MM_YY = rf"\b({DateFormats.DD}[\/\-\s]{DateFormats.MM}[\/\-\s]{DateFormats.YY})" + DD_MM_YYYY = ( + rf"\b({DateFormats.DD}[\/\-\s.]{DateFormats.MM}[\/\-\s.]{DateFormats.YYYY})" + ) DD_MMM = rf"\b({DateFormats.DD}[-\s]{DateFormats.MMM})" DD_MMM_RELAXED = DD_MMM.replace(r"[-\s]", r"(?:[-\s]|)") DD_MMM_YY = rf"\b({DateFormats.DD}[-\s]{DateFormats.MMM}[-\s]{DateFormats.YY})" diff --git a/src/monopoly/constants/statement.py b/src/monopoly/constants/statement.py index 4951af1b..eac90399 100644 --- a/src/monopoly/constants/statement.py +++ b/src/monopoly/constants/statement.py @@ -22,6 +22,7 @@ class BankNames(AutoEnum): OCBC = auto() STANDARD_CHARTERED = auto() UOB = auto() + ZKB = auto() class InternalBankNames(AutoEnum): @@ -167,3 +168,10 @@ class DebitTransactionPatterns(RegexEnum): + SharedPatterns.AMOUNT_EXTENDED_WITHOUT_EOL + SharedPatterns.BALANCE ) + ZKB = ( + rf"(?P{ISO8601.DD_MM_YYYY})\s+" + + SharedPatterns.DESCRIPTION + + r"(?P\d{1,3}(\'\d{3})*(\.\d+)?)\s+" + + rf"(?P{ISO8601.DD_MM_YYYY})\s+" + + SharedPatterns.BALANCE + ) diff --git a/src/monopoly/pipeline.py b/src/monopoly/pipeline.py index 4658e068..c449d3be 100644 --- a/src/monopoly/pipeline.py +++ b/src/monopoly/pipeline.py @@ -73,7 +73,8 @@ def convert_date(transaction: Transaction, transaction_date_order: DateOrder): e.g. if the statement month is Jan/Feb 2024, transactions from Oct/Nov/Dec should be attributed to the previous year. """ - transaction.date += f" {statement_date.year}" + if str(statement_date.year) not in transaction.date: + transaction.date += f" {statement_date.year}" parsed_date = parse( transaction.date, settings=transaction_date_order.settings, diff --git a/src/monopoly/statements/debit_statement.py b/src/monopoly/statements/debit_statement.py index f408f8f8..b34f34b8 100644 --- a/src/monopoly/statements/debit_statement.py +++ b/src/monopoly/statements/debit_statement.py @@ -56,18 +56,27 @@ def get_debit_suffix(self, transaction_match: TransactionMatch) -> str | None: @lru_cache def get_withdrawal_pos(self, page_number: int) -> int | None: - return self.get_column_pos("withdraw", page_number=page_number) + common_names = ["withdraw", "debit"] + for name in common_names: + if pos := self.get_column_pos(name, page_number=page_number): + return pos + logger.warning("`withdrawal` column not found in header") + return False @lru_cache def get_deposit_pos(self, page_number: int) -> int | None: - return self.get_column_pos("deposit", page_number=page_number) + common_names = ["deposit", "credit"] + for name in common_names: + if pos := self.get_column_pos(name, page_number=page_number): + return pos + logger.warning("`deposit` column not found in header") + return False @lru_cache def get_column_pos(self, column_type: str, page_number: int) -> int | None: pattern = re.compile(rf"{column_type}[\w()$]*", re.IGNORECASE) if match := pattern.search(self.header): return self.get_header_pos(match.group(), page_number) - logger.warning(f"`{column_type}` column not found in header") return None @lru_cache diff --git a/src/monopoly/statements/transaction.py b/src/monopoly/statements/transaction.py index a65c7b38..d9012cfa 100644 --- a/src/monopoly/statements/transaction.py +++ b/src/monopoly/statements/transaction.py @@ -104,14 +104,16 @@ def remove_extra_whitespace(cls, value: str) -> str: @field_validator(Columns.AMOUNT, mode="before") def prepare_amount_for_float_coercion(cls, amount: str) -> str: """ - Replaces commas, whitespaces and parentheses in string representation of floats + Replaces commas, whitespaces, apostrophes and parentheses in string + representation of floats e.g. + 1'234.00 -> 1234.00 1,234.00 -> 1234.00 (-10.00) -> -10.00 (-1.56 ) -> -1.56 """ if isinstance(amount, str): - return re.sub(r"[,)(\s]", "", amount) + return re.sub(r"[,)(\s']", "", amount) return amount # pylint: disable=bad-classmethod-argument