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
|
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")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue