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 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 14:38:35 -05:00
parent eb4bfda1db
commit 8360de71ef
9 changed files with 282 additions and 0 deletions
+6
View File
@@ -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
+24
View File
@@ -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")
+13
View File
@@ -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))
+24
View File
@@ -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()
+21
View File
@@ -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")
+26
View File
@@ -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()
+35
View File
@@ -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()