Backing transaction ledger: Postings replace the applied field #20

Merged
claude-code merged 7 commits from feat/19-posting-ledger into main 2026-04-17 17:54:16 -06:00
2 changed files with 163 additions and 5 deletions
Showing only changes of commit 517578f4f3 - Show all commits

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