7 Commits
Author SHA1 Message Date
ktims d414d0cf09 add neo parser 2026-08-24 09:26:32 -07:00
ktims a15970d292 update actual
update node & actual
2026-05-09 00:32:26 -07:00
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
7 changed files with 77 additions and 21 deletions
+3 -3
View File
@@ -1,14 +1,14 @@
# .pre-commit-config.yaml
repos:
- 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:
- id: ruff
args: ["check", "--select", "I", "--fix"]
- id: ruff-format
- 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:
- id: check-yaml
- id: end-of-file-fixer
@@ -16,6 +16,6 @@ repos:
- id: requirements-txt-fixer
- 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:
- id: flake8
+1 -1
View File
@@ -5,7 +5,7 @@ RUN apk add py3-uv
RUN uv build
# Use an official Node.js runtime as a base
FROM node:20-alpine AS runtime
FROM node:25-alpine AS runtime
# Create app directory
WORKDIR /app
+2 -2
View File
@@ -1,5 +1,5 @@
import datetime
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from uuid import UUID
@@ -12,7 +12,7 @@ class Transaction:
"""
account: UUID
date: date
date: datetime.date
amount: Decimal # Note: decimal dollars, JS shim will convert to cents as described in the API
payee: str # imported_payee in API
notes: str
+63 -13
View File
@@ -1,10 +1,11 @@
import re
from abc import ABC, abstractmethod
from collections.abc import Sequence
from datetime import date, datetime
from decimal import Decimal
from email.message import EmailMessage, Message
from logging import info, warning
from typing import Any, Optional, Sequence
from logging import debug, info, warning
from typing import Any, Optional
from uuid import UUID
from bs4 import BeautifulSoup
@@ -58,6 +59,18 @@ class TransactionParser(ABC):
raise TransactionParsingFailed("No content of message found")
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):
pass
@@ -65,7 +78,7 @@ class TransactionParsingFailed(Exception):
class RogersBankParser(TransactionParser):
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):
@@ -78,17 +91,16 @@ class RogersBankParser(TransactionParser):
)
def extract(self, msg: EmailMessage) -> Optional[Transaction]:
content = self.get_content(msg)
content = self.strip_html(msg)
matches = self.EXTRACT_RE.search(content)
if matches is None:
raise TransactionParsingFailed("No matches for extraction RE")
amount = Decimal(matches[1].replace(",", "")) * -1
date_raw = matches[2]
payee = matches[3]
location = matches[4]
payee = matches[5]
if "Rebate" == location and "CashBack" in payee:
if "CashBack" in payee:
amount = amount * -1
date = datetime.strptime(date_raw, "%b %d, %Y").date()
@@ -97,7 +109,7 @@ class RogersBankParser(TransactionParser):
date=date,
amount=amount,
payee=payee,
notes=f"in {location} (via email)",
notes="(via email)",
imported_id=msg["Message-ID"],
)
@@ -136,8 +148,8 @@ class MBNAParser(TransactionParser):
class BMOParser(TransactionParser):
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
flags=re.MULTILINE,
r"There was a (withdrawal|deposit).*Amount:\s*\$([0-9,]+\.\d{2}).*Account:\s*Ending in ([0-9]+)", # noqa: E501
flags=re.DOTALL,
)
def __init__(self, account_map: dict[int, UUID]):
@@ -147,9 +159,8 @@ class BMOParser(TransactionParser):
return msg["From"] == "bmoalerts@bmo.com"
def extract(self, msg: EmailMessage) -> Optional[Transaction]:
content = self.get_content(msg)
soup = BeautifulSoup(content, "html.parser")
matches = self.EXTRACT_RE.search(soup.get_text())
content = self.strip_html(msg)
matches = self.EXTRACT_RE.search(content)
if matches is None:
raise TransactionParsingFailed("No matches for extraction RE")
@@ -298,3 +309,42 @@ class ScotiaBankParser(TransactionParser):
return self.extract_transaction(msg)
info("Subject `%s` didn't match any extractors", msg["Subject"])
return None
class NeoFinancialParser(TransactionParser):
PURCHASE_EXTRACT_RE = re.compile(
r"your purchase of \$(?P<amount>[0-9]+\.[0-9]{2}) at (?P<payee>.+) on (?P<date>(January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}(st|nd|rd|th)?, \d{4})", # noqa: E501
re.MULTILINE,
)
def __init__(self, account_id: UUID):
self.account_id = account_id
def match(self, msg: Message) -> bool:
return msg["From"] == "Neo Financial <info@neofinancial.com>" and msg[
"Subject"
].startswith("You earned")
def extract_purchase(self, msg: EmailMessage) -> Optional[Transaction]:
content = self.get_content(msg, preferencelist=["plain"])
matches = self.PURCHASE_EXTRACT_RE.search(content)
if matches is None:
debug(f"No match on\n\n{content}")
raise TransactionParsingFailed("no matches for purchase extraction RE")
amount = Decimal(matches["amount"].replace(",", "")) * -1
# Use date from email header, it seems to match the body and includes TZ info
date = parse_email_time(msg["Date"]).date()
return Transaction(
account=self.account_id,
date=date,
amount=amount,
payee=matches["payee"],
notes="via email",
imported_id=msg["Message-ID"],
)
def extract(self, msg: EmailMessage) -> Optional[Transaction]:
if msg["Subject"].startswith("You earned"):
return self.extract_purchase(msg)
return None
+6
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python
import asyncio
import email.policy
import importlib.metadata
import importlib.util
import logging
import os
@@ -129,6 +130,11 @@ class App:
def main():
logging.basicConfig(level=getenv("LOG_LEVEL", "INFO"))
logging.info(
"actual-imap-parser %s starting up",
importlib.metadata.version("actual-imap-poll"),
)
app_config = AppConfig()
logging.debug("Config: %s", app_config)
app = App(app_config, CONFIG_PATH)
asyncio.run(app.run())
+1 -1
View File
@@ -14,7 +14,7 @@
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@actual-app/api": "^25.11.0",
"@actual-app/api": "^26.5.2",
"yargs": "^17.7.2"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "actual-imap-poll"
version = "0.1.1"
version = "0.2.0"
description = "Poll an IMAP mailbox looking for transactions to submit to Actual Budget"
authors = [
{name = "Keenan Tims",email = "ktims@gotroot.ca"}