diff --git a/alembic/versions/cc60e7f73a1c_add_posting_ledger_drop_month_entry_.py b/alembic/versions/cc60e7f73a1c_add_posting_ledger_drop_month_entry_.py new file mode 100644 index 0000000..d45c445 --- /dev/null +++ b/alembic/versions/cc60e7f73a1c_add_posting_ledger_drop_month_entry_.py @@ -0,0 +1,123 @@ +"""add posting ledger, drop month_entry.applied + +Revision ID: cc60e7f73a1c +Revises: a4ec4f8f6e9f +Create Date: 2026-04-17 17:25:53.487094 + +Creates the posting table, seeds one "opening balance" posting per +existing month_entry whose applied value is non-zero, and drops the +applied column from month_entry. After this revision applied is +computed as SUM(posting.amount) over the entry's postings. +""" +from datetime import datetime, timezone +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "cc60e7f73a1c" +down_revision: Union[str, Sequence[str], None] = "a4ec4f8f6e9f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Create posting table + op.create_table( + "posting", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("month_entry_id", sa.Integer(), nullable=False), + sa.Column("occurred_on", sa.Date(), nullable=False), + sa.Column("amount", sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column("description", sa.String(length=256), nullable=True), + sa.Column("payee", sa.String(length=128), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["month_entry_id"], ["month_entry.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("posting", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_posting_month_entry_id"), + ["month_entry_id"], + unique=False, + ) + + # 2. Seed opening-balance postings from the existing month_entry.applied + conn = op.get_bind() + rows = conn.execute( + sa.text( + "SELECT me.id, me.applied, m.activated_at, m.created_at " + "FROM month_entry me JOIN month m ON m.id = me.month_id " + "WHERE me.applied IS NOT NULL AND me.applied != 0" + ) + ).fetchall() + now = datetime.now(timezone.utc).replace(tzinfo=None) + for row in rows: + me_id, applied, activated_at, created_at = row + occurred = activated_at or created_at + # Truncate the timestamp to a date for the DATE column + if hasattr(occurred, "date"): + occurred = occurred.date() + if isinstance(occurred, str): + # SQLite may give back an ISO string; take the date portion + occurred = occurred[:10] + conn.execute( + sa.text( + "INSERT INTO posting " + "(month_entry_id, occurred_on, amount, description, created_at, updated_at) " + "VALUES (:me_id, :occurred, :amount, :desc, :now, :now)" + ), + { + "me_id": me_id, + "occurred": occurred, + "amount": applied, + "desc": "opening balance", + "now": now, + }, + ) + + # 3. Drop the applied column from month_entry + with op.batch_alter_table("month_entry", schema=None) as batch_op: + batch_op.drop_column("applied") + + +def downgrade() -> None: + # Add the applied column back with a default of 0.00 for NOT NULL + with op.batch_alter_table("month_entry", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "applied", + sa.NUMERIC(precision=10, scale=2), + server_default=sa.text("'0.00'"), + nullable=False, + ) + ) + + # Restore applied as SUM of postings + conn = op.get_bind() + conn.execute( + sa.text( + "UPDATE month_entry SET applied = (" + "SELECT COALESCE(SUM(amount), 0) FROM posting " + "WHERE posting.month_entry_id = month_entry.id" + ")" + ) + ) + + with op.batch_alter_table("posting", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_posting_month_entry_id")) + op.drop_table("posting") diff --git a/src/quartermaster/models.py b/src/quartermaster/models.py index 6d8f25a..8c44d8f 100644 --- a/src/quartermaster/models.py +++ b/src/quartermaster/models.py @@ -1,11 +1,12 @@ from __future__ import annotations import enum -from datetime import datetime +from datetime import date, datetime from decimal import Decimal from sqlalchemy import ( CheckConstraint, + Date, DateTime, Enum, ForeignKey, @@ -132,10 +133,6 @@ class MonthEntry(Base): ) name: Mapped[str] = mapped_column(String(128), nullable=False) planned: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) - applied: Mapped[Decimal] = mapped_column( - Numeric(10, 2), nullable=False, default=Decimal("0.00"), - server_default="0.00", - ) notes: Mapped[str | None] = mapped_column(String(1024), nullable=True) origin_name: Mapped[str | None] = mapped_column(String(128), nullable=True) origin_planned: Mapped[Decimal | None] = mapped_column( @@ -155,6 +152,18 @@ class MonthEntry(Base): ) month: Mapped[Month] = relationship(back_populates="entries") + postings: Mapped[list["Posting"]] = relationship( + back_populates="entry", + cascade="all, delete-orphan", + order_by="Posting.occurred_on.desc(), Posting.id.desc()", + lazy="selectin", + ) + + @property + def applied(self) -> Decimal: + return sum((p.amount for p in self.postings), Decimal("0")).quantize( + Decimal("0.01") + ) class MonthDebtTarget(Base): @@ -177,3 +186,29 @@ class MonthDebtTarget(Base): month: Mapped[Month] = relationship(back_populates="target") entry: Mapped[MonthEntry | None] = relationship(MonthEntry, lazy="joined") + + +class Posting(Base): + __tablename__ = "posting" + + id: Mapped[int] = mapped_column(primary_key=True) + month_entry_id: Mapped[int] = mapped_column( + ForeignKey("month_entry.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + occurred_on: Mapped[date] = mapped_column(Date, nullable=False) + amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) + description: Mapped[str | None] = mapped_column(String(256), nullable=True) + payee: Mapped[str | None] = mapped_column(String(128), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + entry: Mapped[MonthEntry] = relationship(back_populates="postings")