From 8360de71efe245b646768bf2ef8092a7bc73287f Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 10 Feb 2026 14:38:35 -0500 Subject: [PATCH] feat: add SQLAlchemy database models and tests Add the data layer for the spending analysis app including models for household members, accounts, categories, transactions, categorization rules, and CSV import mappings. All models use SQLAlchemy 2.0 mapped columns with proper foreign key relationships. Includes db.py with Base class, engine/session factories, and 6 passing tests. Co-Authored-By: Claude Opus 4.6 --- src/db.py | 21 +++++++ src/models/__init__.py | 6 ++ src/models/account.py | 24 ++++++++ src/models/category.py | 13 +++++ src/models/csv_mapping.py | 24 ++++++++ src/models/household.py | 21 +++++++ src/models/rule.py | 26 +++++++++ src/models/transaction.py | 35 +++++++++++ tests/models/test_models.py | 112 ++++++++++++++++++++++++++++++++++++ 9 files changed, 282 insertions(+) create mode 100644 src/db.py create mode 100644 src/models/account.py create mode 100644 src/models/category.py create mode 100644 src/models/csv_mapping.py create mode 100644 src/models/household.py create mode 100644 src/models/rule.py create mode 100644 src/models/transaction.py create mode 100644 tests/models/test_models.py diff --git a/src/db.py b/src/db.py new file mode 100644 index 0000000..1b8118c --- /dev/null +++ b/src/db.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session + + +class Base(DeclarativeBase): + pass + + +def get_engine(db_path: Path | None = None): + if db_path is None: + db_path = Path.home() / ".spending_analysis" / "spending.db" + db_path.parent.mkdir(parents=True, exist_ok=True) + return create_engine(f"sqlite:///{db_path}") + + +def get_session(db_path: Path | None = None) -> Session: + engine = get_engine(db_path) + Base.metadata.create_all(engine) + return Session(engine) diff --git a/src/models/__init__.py b/src/models/__init__.py index e69de29..52d7767 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -0,0 +1,6 @@ +from src.models.household import HouseholdMember +from src.models.account import Account +from src.models.category import Category +from src.models.transaction import Transaction +from src.models.rule import CategorizationRule +from src.models.csv_mapping import CsvMapping diff --git a/src/models/account.py b/src/models/account.py new file mode 100644 index 0000000..71c29d6 --- /dev/null +++ b/src/models/account.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db import Base + +if TYPE_CHECKING: + from src.models.household import HouseholdMember + + +class Account(Base): + __tablename__ = "accounts" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + institution: Mapped[str] = mapped_column(String(100)) + account_type: Mapped[str] = mapped_column(String(20)) + owner_id: Mapped[int | None] = mapped_column(ForeignKey("household_members.id")) + is_shared: Mapped[bool] = mapped_column(default=False) + + owner: Mapped[HouseholdMember | None] = relationship(back_populates="accounts") diff --git a/src/models/category.py b/src/models/category.py new file mode 100644 index 0000000..0083287 --- /dev/null +++ b/src/models/category.py @@ -0,0 +1,13 @@ +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from src.db import Base + + +class Category(Base): + __tablename__ = "categories" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100), unique=True) + default_tag: Mapped[str | None] = mapped_column(String(20)) + icon: Mapped[str | None] = mapped_column(String(50)) diff --git a/src/models/csv_mapping.py b/src/models/csv_mapping.py new file mode 100644 index 0000000..e0eef3b --- /dev/null +++ b/src/models/csv_mapping.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db import Base + +if TYPE_CHECKING: + from src.models.account import Account + + +class CsvMapping(Base): + __tablename__ = "csv_mappings" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + fingerprint: Mapped[str] = mapped_column(String(500)) + column_map: Mapped[str] = mapped_column(Text) + amount_logic: Mapped[str] = mapped_column(String(50)) + account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id")) + + account: Mapped[Account] = relationship() diff --git a/src/models/household.py b/src/models/household.py new file mode 100644 index 0000000..da66a8b --- /dev/null +++ b/src/models/household.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship + +from src.db import Base + +if TYPE_CHECKING: + from src.models.account import Account + + +class HouseholdMember(Base): + __tablename__ = "household_members" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + relationship: Mapped[str] = mapped_column(String(50)) + + accounts: Mapped[list[Account]] = sa_relationship(back_populates="owner") diff --git a/src/models/rule.py b/src/models/rule.py new file mode 100644 index 0000000..fe9db1d --- /dev/null +++ b/src/models/rule.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db import Base + +if TYPE_CHECKING: + from src.models.category import Category + from src.models.household import HouseholdMember + + +class CategorizationRule(Base): + __tablename__ = "categorization_rules" + + id: Mapped[int] = mapped_column(primary_key=True) + pattern: Mapped[str] = mapped_column(String(300)) + category_id: Mapped[int] = mapped_column(ForeignKey("categories.id")) + tag_override: Mapped[str | None] = mapped_column(String(20)) + attributed_to_id: Mapped[int | None] = mapped_column(ForeignKey("household_members.id")) + priority: Mapped[int] = mapped_column(default=0) + + category: Mapped[Category] = relationship() + attributed_to: Mapped[HouseholdMember | None] = relationship() diff --git a/src/models/transaction.py b/src/models/transaction.py new file mode 100644 index 0000000..83c8dd1 --- /dev/null +++ b/src/models/transaction.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import Date, ForeignKey, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db import Base + +if TYPE_CHECKING: + from src.models.account import Account + from src.models.category import Category + from src.models.household import HouseholdMember + + +class Transaction(Base): + __tablename__ = "transactions" + + id: Mapped[int] = mapped_column(primary_key=True) + date: Mapped[datetime.date] = mapped_column(Date) + amount: Mapped[float] = mapped_column(Numeric(10, 2)) + description: Mapped[str] = mapped_column(String(300)) + raw_description: Mapped[str | None] = mapped_column(Text) + account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id")) + category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id")) + attributed_to_id: Mapped[int | None] = mapped_column(ForeignKey("household_members.id")) + tag: Mapped[str | None] = mapped_column(String(20)) + source_category: Mapped[str | None] = mapped_column(String(100)) + is_transfer: Mapped[bool] = mapped_column(default=False) + transfer_pair_id: Mapped[int | None] = mapped_column(ForeignKey("transactions.id")) + + account: Mapped[Account] = relationship() + category: Mapped[Category | None] = relationship() + attributed_to: Mapped[HouseholdMember | None] = relationship() diff --git a/tests/models/test_models.py b/tests/models/test_models.py new file mode 100644 index 0000000..fc7c910 --- /dev/null +++ b/tests/models/test_models.py @@ -0,0 +1,112 @@ +import datetime + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from src.db import Base +from src.models.household import HouseholdMember +from src.models.account import Account +from src.models.category import Category +from src.models.transaction import Transaction +from src.models.rule import CategorizationRule +from src.models.csv_mapping import CsvMapping + + +def make_session(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + return Session(engine) + + +def test_create_household_member(): + session = make_session() + member = HouseholdMember(name="Andrew", relationship="self") + session.add(member) + session.commit() + assert member.id is not None + assert member.name == "Andrew" + + +def test_create_account_with_owner(): + session = make_session() + member = HouseholdMember(name="Andrew", relationship="self") + session.add(member) + session.flush() + account = Account( + name="Chase Freedom", + institution="Chase", + account_type="credit", + owner_id=member.id, + ) + session.add(account) + session.commit() + assert account.owner.name == "Andrew" + + +def test_create_category(): + session = make_session() + cat = Category(name="Groceries", default_tag="needs") + session.add(cat) + session.commit() + assert cat.id is not None + + +def test_create_transaction(): + session = make_session() + member = HouseholdMember(name="Andrew", relationship="self") + session.add(member) + session.flush() + account = Account(name="Checking", institution="Wells Fargo", account_type="checking", owner_id=member.id) + cat = Category(name="Groceries", default_tag="needs") + session.add_all([account, cat]) + session.flush() + txn = Transaction( + date=datetime.date(2026, 1, 15), + amount=-48.52, + description="WAL-MART #7181", + raw_description="PURCHASE AUTHORIZED ON 01/14 WAL-MART #7181 BEAUFORT SC CARD 5360", + account_id=account.id, + category_id=cat.id, + attributed_to_id=member.id, + tag="needs", + ) + session.add(txn) + session.commit() + assert txn.id is not None + assert txn.account.name == "Checking" + assert txn.category.name == "Groceries" + + +def test_create_categorization_rule(): + session = make_session() + cat = Category(name="Groceries", default_tag="needs") + session.add(cat) + session.flush() + rule = CategorizationRule( + pattern="WAL-MART", + category_id=cat.id, + priority=10, + ) + session.add(rule) + session.commit() + assert rule.id is not None + + +def test_create_csv_mapping(): + session = make_session() + member = HouseholdMember(name="Andrew", relationship="self") + session.add(member) + session.flush() + account = Account(name="Chase", institution="Chase", account_type="credit", owner_id=member.id) + session.add(account) + session.flush() + mapping = CsvMapping( + name="Chase Credit Card", + fingerprint="Transaction Date,Post Date,Description,Category,Type,Amount,Memo", + column_map='{"date": "Transaction Date", "amount": "Amount", "description": "Description"}', + amount_logic="signed", + account_id=account.id, + ) + session.add(mapping) + session.commit() + assert mapping.id is not None