Backing transaction ledger: Postings replace the applied field #20
2 changed files with 163 additions and 5 deletions
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue