add bmo parser, some refactors

This commit is contained in:
2025-04-24 18:17:20 -07:00
parent bb1fcb3d94
commit d1845cc5d9
6 changed files with 94 additions and 43 deletions

View File

@@ -1,12 +1,14 @@
import re
from abc import ABC
from abc import abstractmethod
from datetime import datetime
from abc import ABC, abstractmethod
from datetime import date, datetime
from decimal import Decimal
from email.message import EmailMessage
from email.message import Message
from email.message import EmailMessage, Message
from logging import info
from typing import Optional
from uuid import UUID
from bs4 import BeautifulSoup
from model import Transaction
@@ -25,7 +27,7 @@ class TransactionParser(ABC):
pass
@abstractmethod
def extract(self, msg: EmailMessage) -> Transaction:
def extract(self, msg: EmailMessage) -> Optional[Transaction]:
"""
Extracts transaction details from the given email message.
@@ -59,11 +61,11 @@ class RogersBankParser(TransactionParser):
and msg["Subject"] == "Purchase amount alert"
)
def extract(self, msg: EmailMessage) -> Transaction:
body = msg.get_body()
def extract(self, msg: EmailMessage) -> Optional[Transaction]:
body = msg.get_content()
if body is None:
raise TransactionParsingFailed("No body of message found")
matches = self.EXTRACT_RE.search(body.as_string())
matches = self.EXTRACT_RE.search(body)
if matches is None:
raise TransactionParsingFailed("No matches for extraction RE")
amount = Decimal(matches[1])
@@ -88,27 +90,68 @@ class MBNAParser(TransactionParser):
def __init__(self, account_id: UUID):
self.account_id = account_id
def match(self, msg: Message):
def match(self, msg: Message) -> bool:
return (
msg["From"] == "MBNA Notifications <noreply@mbna.ca>"
and msg["Subject"] == "MBNA - Transaction Alert"
)
def extract(self, msg: EmailMessage) -> Transaction:
body = msg.get_body()
def extract(self, msg: EmailMessage) -> Optional[Transaction]:
body = msg.get_content()
if body is None:
raise TransactionParsingFailed("No body of message found")
matches = self.EXTRACT_RE.search(body.as_string())
matches = self.EXTRACT_RE.search(body)
if matches is None:
raise TransactionParsingFailed("No matches for extraction RE")
amount = Decimal(matches[1])
payee = matches[2]
date = matches[5]
date_raw = matches[5]
return Transaction(
account=self.account_id,
date=date,
date=date.fromisoformat(date_raw),
amount=amount,
payee=payee,
notes="via email",
imported_id=msg["Message-ID"],
)
class BMOParser(TransactionParser):
EXTRACT_RE = re.compile(
r"We want to let you know that a (withdrawal|deposit) of\s+\$(\d+\.\d{2})\s+has been made (?:to|from) your account ending\s+in\s+(\d{3})", # noqa: E501
flags=re.MULTILINE,
)
def __init__(self, account_map: dict[int, UUID]):
self._account_map = account_map
def match(self, msg: Message) -> bool:
return msg["From"] == "bmoalerts@bmo.com"
def extract(self, msg: EmailMessage) -> Optional[Transaction]:
body = msg.get_content()
if body is None:
raise TransactionParsingFailed("No body of message found")
soup = BeautifulSoup(body, "html.parser")
matches = self.EXTRACT_RE.search(soup.get_text())
if matches is None:
raise TransactionParsingFailed("No matches for extraction RE")
amount = Decimal(matches[2])
if matches[1] == "withdrawal":
amount = amount * -1
date_raw = msg["Date"]
date = datetime.strptime(date_raw, "%a, %d %b %Y %H:%M:%S %z").date()
account_ref = int(matches[3])
if account_ref not in self._account_map:
info("Account %s not in account map", account_ref)
return None
account_id = self._account_map[account_ref]
return Transaction(
account=account_id,
date=date,
amount=amount,
payee="",
notes="via email",
imported_id=msg["Message-ID"],
)