feat(db): add Posting model, derive MonthEntry.applied, seed opening balances

Posting is a child of MonthEntry with occurred_on, amount, optional
description and payee. Cascade delete so removing an entry wipes its
ledger. Ordered on load by occurred_on DESC for readable UIs.

MonthEntry.applied becomes a @property summing posting amounts. The
stored applied column is dropped in the same migration.

The migration walks existing month_entry rows: for every non-zero
applied value, it inserts one opening-balance posting on the month's
activated_at (or created_at) date with description "opening balance"
and amount equal to the existing applied. Empty applied values get
no opening posting. Closed months go through the same path; their
totals stay intact via that single seeded row.

Downgrade is symmetric: re-adds the column and populates from
SUM(postings).

Refs #19

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
archeious 2026-04-17 17:34:34 -06:00
parent 52e3217aee
commit 517578f4f3
2 changed files with 163 additions and 5 deletions

View file

@ -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")

View file

@ -1,11 +1,12 @@
from __future__ import annotations from __future__ import annotations
import enum import enum
from datetime import datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from sqlalchemy import ( from sqlalchemy import (
CheckConstraint, CheckConstraint,
Date,
DateTime, DateTime,
Enum, Enum,
ForeignKey, ForeignKey,
@ -132,10 +133,6 @@ class MonthEntry(Base):
) )
name: Mapped[str] = mapped_column(String(128), nullable=False) name: Mapped[str] = mapped_column(String(128), nullable=False)
planned: Mapped[Decimal] = mapped_column(Numeric(10, 2), 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) notes: Mapped[str | None] = mapped_column(String(1024), nullable=True)
origin_name: Mapped[str | None] = mapped_column(String(128), nullable=True) origin_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
origin_planned: Mapped[Decimal | None] = mapped_column( origin_planned: Mapped[Decimal | None] = mapped_column(
@ -155,6 +152,18 @@ class MonthEntry(Base):
) )
month: Mapped[Month] = relationship(back_populates="entries") 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): class MonthDebtTarget(Base):
@ -177,3 +186,29 @@ class MonthDebtTarget(Base):
month: Mapped[Month] = relationship(back_populates="target") month: Mapped[Month] = relationship(back_populates="target")
entry: Mapped[MonthEntry | None] = relationship(MonthEntry, lazy="joined") 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")