7 Commits

Author SHA1 Message Date
ktims 7b6b497c0f bump version 0.1.3 2026-05-09 00:08:48 -07:00
ktims 097128c290 fix rogers parser for new format 2026-05-09 00:08:32 -07:00
ktims 2662747ebe fix bmo parser for new format / lints 2026-05-09 00:00:38 -07:00
ktims 93778beb31 bump version 0.1.2 2025-11-23 22:19:35 -08:00
ktims 6bb281098d add version print 2025-11-23 22:19:23 -08:00
ktims 35381a4c1d add CIBC purchase parser 2025-11-23 22:16:35 -08:00
ktims e690a2c4d2 Fix Scotia parser wrong direction 2025-11-23 22:16:10 -08:00
5 changed files with 67 additions and 19 deletions
+3 -3
View File
@@ -1,14 +1,14 @@
# .pre-commit-config.yaml # .pre-commit-config.yaml
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.5 # Check for the latest version rev: v0.15.12 # Check for the latest version
hooks: hooks:
- id: ruff - id: ruff
args: ["check", "--select", "I", "--fix"] args: ["check", "--select", "I", "--fix"]
- id: ruff-format - id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0 # Check for the latest version rev: v6.0.0 # Check for the latest version
hooks: hooks:
- id: check-yaml - id: check-yaml
- id: end-of-file-fixer - id: end-of-file-fixer
@@ -16,6 +16,6 @@ repos:
- id: requirements-txt-fixer - id: requirements-txt-fixer
- repo: https://github.com/PyCQA/flake8 - repo: https://github.com/PyCQA/flake8
rev: 6.0.0 # Check for the latest version rev: 7.3.0 # Check for the latest version
hooks: hooks:
- id: flake8 - id: flake8
+2 -2
View File
@@ -1,5 +1,5 @@
import datetime
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date
from decimal import Decimal from decimal import Decimal
from uuid import UUID from uuid import UUID
@@ -12,7 +12,7 @@ class Transaction:
""" """
account: UUID account: UUID
date: date date: datetime.date
amount: Decimal # Note: decimal dollars, JS shim will convert to cents as described in the API amount: Decimal # Note: decimal dollars, JS shim will convert to cents as described in the API
payee: str # imported_payee in API payee: str # imported_payee in API
notes: str notes: str
+55 -13
View File
@@ -58,6 +58,18 @@ class TransactionParser(ABC):
raise TransactionParsingFailed("No content of message found") raise TransactionParsingFailed("No content of message found")
return content return content
@staticmethod
def strip_html(msg: EmailMessage) -> str:
body = msg.get_body(preferencelist=("html", "plain"))
if body is None:
raise TransactionParsingFailed("No HTML body of message found")
content = body.get_content()
if content is None:
raise TransactionParsingFailed("No content of message found")
soup = BeautifulSoup(content, "html.parser")
return soup.get_text()
class TransactionParsingFailed(Exception): class TransactionParsingFailed(Exception):
pass pass
@@ -65,7 +77,7 @@ class TransactionParsingFailed(Exception):
class RogersBankParser(TransactionParser): class RogersBankParser(TransactionParser):
EXTRACT_RE = re.compile( EXTRACT_RE = re.compile(
r"Attempt of \$([0-9,]+\.\d{2}) was made on ([A-z]{3} \d{1,2}, \d{4})[^<]*at ([^<]+) in ([^<]+)." # noqa: E501 r"\$([0-9,]+\.\d{2}) on ((Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{2}, [0-9]{4}) on your credit card ending in ([0-9]{4}). Details: ([^.]+)" # noqa: E501
) )
def __init__(self, account_id: UUID): def __init__(self, account_id: UUID):
@@ -78,17 +90,16 @@ class RogersBankParser(TransactionParser):
) )
def extract(self, msg: EmailMessage) -> Optional[Transaction]: def extract(self, msg: EmailMessage) -> Optional[Transaction]:
content = self.get_content(msg) content = self.strip_html(msg)
matches = self.EXTRACT_RE.search(content) matches = self.EXTRACT_RE.search(content)
if matches is None: if matches is None:
raise TransactionParsingFailed("No matches for extraction RE") raise TransactionParsingFailed("No matches for extraction RE")
amount = Decimal(matches[1].replace(",", "")) * -1 amount = Decimal(matches[1].replace(",", "")) * -1
date_raw = matches[2] date_raw = matches[2]
payee = matches[3] payee = matches[5]
location = matches[4]
if "Rebate" == location and "CashBack" in payee: if "CashBack" in payee:
amount = amount * -1 amount = amount * -1
date = datetime.strptime(date_raw, "%b %d, %Y").date() date = datetime.strptime(date_raw, "%b %d, %Y").date()
@@ -97,7 +108,7 @@ class RogersBankParser(TransactionParser):
date=date, date=date,
amount=amount, amount=amount,
payee=payee, payee=payee,
notes=f"in {location} (via email)", notes="(via email)",
imported_id=msg["Message-ID"], imported_id=msg["Message-ID"],
) )
@@ -136,8 +147,8 @@ class MBNAParser(TransactionParser):
class BMOParser(TransactionParser): class BMOParser(TransactionParser):
EXTRACT_RE = re.compile( EXTRACT_RE = re.compile(
r"We want to let you know that a (withdrawal|deposit) of\s+\$([0-9,]+\.\d{2})\s+has been made (?:to|from) your account ending\s+in\s+(\d{3})", # noqa: E501 r"There was a (withdrawal|deposit).*Amount:\s*\$([0-9,]+\.\d{2}).*Account:\s*Ending in ([0-9]+)", # noqa: E501
flags=re.MULTILINE, flags=re.DOTALL,
) )
def __init__(self, account_map: dict[int, UUID]): def __init__(self, account_map: dict[int, UUID]):
@@ -147,9 +158,8 @@ class BMOParser(TransactionParser):
return msg["From"] == "bmoalerts@bmo.com" return msg["From"] == "bmoalerts@bmo.com"
def extract(self, msg: EmailMessage) -> Optional[Transaction]: def extract(self, msg: EmailMessage) -> Optional[Transaction]:
content = self.get_content(msg) content = self.strip_html(msg)
soup = BeautifulSoup(content, "html.parser") matches = self.EXTRACT_RE.search(content)
matches = self.EXTRACT_RE.search(soup.get_text())
if matches is None: if matches is None:
raise TransactionParsingFailed("No matches for extraction RE") raise TransactionParsingFailed("No matches for extraction RE")
@@ -178,11 +188,16 @@ class CIBCParser(TransactionParser):
flags=re.MULTILINE, flags=re.MULTILINE,
) )
PURCHASE_EXTRACT_RE = re.compile(
r"made a purchase with your CIBC.*ending in (?P<account>\d{4}) for \$(?P<amount>\d+\.\d{2}) at (?P<payee>.*?).<br", # noqa: E501
re.MULTILINE,
)
def __init__(self, account_map: dict[int, UUID]): def __init__(self, account_map: dict[int, UUID]):
self._account_map = account_map self._account_map = account_map
def match(self, msg: Message) -> bool: def match(self, msg: Message) -> bool:
return msg["From"] == "CIBC Banking <mailbox.noreply@cibc.com>" return msg["From"] == "CIBC Banking <Mailbox.noreply@cibc.com>"
def extract_payment(self, msg: EmailMessage): def extract_payment(self, msg: EmailMessage):
content = self.get_content(msg) content = self.get_content(msg)
@@ -209,10 +224,37 @@ class CIBCParser(TransactionParser):
imported_id=msg["Message-ID"], imported_id=msg["Message-ID"],
) )
def extract_purchase(self, msg: EmailMessage) -> Optional[Transaction]:
content = self.get_content(msg)
matches = self.PURCHASE_EXTRACT_RE.search(content)
if matches is None:
raise TransactionParsingFailed("no matches for extraction RE")
amount = Decimal(matches["amount"].replace(",", "")) * -1
date = parse_email_time(msg["Date"]).date()
account_ref = int(matches["account"])
if account_ref not in self._account_map:
warning("Account %s not in account map, skipping transaction", account_ref)
return None
account_id = self._account_map[account_ref]
return Transaction(
account=account_id,
date=date,
amount=amount,
payee=matches["payee"],
notes="via email",
imported_id=msg["Message-ID"],
)
def extract(self, msg: EmailMessage) -> Optional[Transaction]: def extract(self, msg: EmailMessage) -> Optional[Transaction]:
match msg["Subject"]: match msg["Subject"]:
case "New payment to your credit card": case "New payment to your credit card":
return self.extract_payment(msg) return self.extract_payment(msg)
case "New purchase on your credit card":
return self.extract_purchase(msg)
return None return None
@@ -235,7 +277,7 @@ class ScotiaBankParser(TransactionParser):
if matches is None: if matches is None:
raise TransactionParsingFailed("no matches for extraction RE") raise TransactionParsingFailed("no matches for extraction RE")
amount = Decimal(matches["amount"].replace(",", "")) amount = Decimal(matches["amount"].replace(",", "")) * -1
date = parse_email_time(msg["Date"]).date() date = parse_email_time(msg["Date"]).date()
if matches["account"] not in self._account_map: if matches["account"] not in self._account_map:
+6
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python #!/usr/bin/env python
import asyncio import asyncio
import email.policy import email.policy
import importlib.metadata
import importlib.util import importlib.util
import logging import logging
import os import os
@@ -129,6 +130,11 @@ class App:
def main(): def main():
logging.basicConfig(level=getenv("LOG_LEVEL", "INFO")) logging.basicConfig(level=getenv("LOG_LEVEL", "INFO"))
logging.info(
"actual-imap-parser %s starting up",
importlib.metadata.version("actual-imap-poll"),
)
app_config = AppConfig() app_config = AppConfig()
logging.debug("Config: %s", app_config)
app = App(app_config, CONFIG_PATH) app = App(app_config, CONFIG_PATH)
asyncio.run(app.run()) asyncio.run(app.run())
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "actual-imap-poll" name = "actual-imap-poll"
version = "0.1.1" version = "0.1.3"
description = "Poll an IMAP mailbox looking for transactions to submit to Actual Budget" description = "Poll an IMAP mailbox looking for transactions to submit to Actual Budget"
authors = [ authors = [
{name = "Keenan Tims",email = "ktims@gotroot.ca"} {name = "Keenan Tims",email = "ktims@gotroot.ca"}