Backing transaction ledger: Postings replace the applied field (#20)
This commit is contained in:
commit
19cac8f08b
14 changed files with 1169 additions and 147 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")
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal, InvalidOperation
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
@ -24,6 +24,7 @@ from quartermaster.models import (
|
||||||
MonthDebtTarget,
|
MonthDebtTarget,
|
||||||
MonthEntry,
|
MonthEntry,
|
||||||
MonthState,
|
MonthState,
|
||||||
|
Posting,
|
||||||
Section,
|
Section,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -125,7 +126,6 @@ def create_month(db: Session, year_month: str) -> Month:
|
||||||
section=e.section,
|
section=e.section,
|
||||||
name=e.name,
|
name=e.name,
|
||||||
planned=e.amount,
|
planned=e.amount,
|
||||||
applied=Decimal("0.00"),
|
|
||||||
notes=e.notes,
|
notes=e.notes,
|
||||||
origin_name=e.name,
|
origin_name=e.name,
|
||||||
origin_planned=e.amount,
|
origin_planned=e.amount,
|
||||||
|
|
@ -238,7 +238,6 @@ def add_month_entry(
|
||||||
section=section,
|
section=section,
|
||||||
name=name.strip(),
|
name=name.strip(),
|
||||||
planned=planned,
|
planned=planned,
|
||||||
applied=Decimal("0.00"),
|
|
||||||
notes=_clean_notes(notes),
|
notes=_clean_notes(notes),
|
||||||
origin_name=None,
|
origin_name=None,
|
||||||
origin_planned=None,
|
origin_planned=None,
|
||||||
|
|
@ -277,7 +276,6 @@ def update_month_entry(
|
||||||
*,
|
*,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
planned: Decimal | None = None,
|
planned: Decimal | None = None,
|
||||||
applied: Decimal | None = None,
|
|
||||||
notes: str | None | object = _NOTES_SENTINEL,
|
notes: str | None | object = _NOTES_SENTINEL,
|
||||||
) -> MonthEntry | None:
|
) -> MonthEntry | None:
|
||||||
entry = get_month_entry(db, month, entry_id)
|
entry = get_month_entry(db, month, entry_id)
|
||||||
|
|
@ -287,8 +285,6 @@ def update_month_entry(
|
||||||
entry.name = name.strip()
|
entry.name = name.strip()
|
||||||
if planned is not None:
|
if planned is not None:
|
||||||
entry.planned = planned
|
entry.planned = planned
|
||||||
if applied is not None:
|
|
||||||
entry.applied = applied
|
|
||||||
if notes is not _NOTES_SENTINEL:
|
if notes is not _NOTES_SENTINEL:
|
||||||
entry.notes = _clean_notes(notes) # type: ignore[arg-type]
|
entry.notes = _clean_notes(notes) # type: ignore[arg-type]
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -296,6 +292,85 @@ def update_month_entry(
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def add_posting(
|
||||||
|
db: Session,
|
||||||
|
month: Month,
|
||||||
|
entry_id: int,
|
||||||
|
occurred_on: date,
|
||||||
|
amount: Decimal,
|
||||||
|
description: str | None = None,
|
||||||
|
payee: str | None = None,
|
||||||
|
) -> Posting | None:
|
||||||
|
entry = get_month_entry(db, month, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
posting = Posting(
|
||||||
|
month_entry_id=entry.id,
|
||||||
|
occurred_on=occurred_on,
|
||||||
|
amount=amount,
|
||||||
|
description=_clean_notes(description),
|
||||||
|
payee=_clean_notes(payee),
|
||||||
|
)
|
||||||
|
db.add(posting)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(posting)
|
||||||
|
return posting
|
||||||
|
|
||||||
|
|
||||||
|
def get_posting(
|
||||||
|
db: Session, month: Month, posting_id: int
|
||||||
|
) -> Posting | None:
|
||||||
|
posting = db.get(Posting, posting_id)
|
||||||
|
if posting is None:
|
||||||
|
return None
|
||||||
|
# Ensure posting belongs to this month via its entry
|
||||||
|
if posting.entry is None or posting.entry.month_id != month.id:
|
||||||
|
return None
|
||||||
|
return posting
|
||||||
|
|
||||||
|
|
||||||
|
_POSTING_SENTINEL = object()
|
||||||
|
|
||||||
|
|
||||||
|
def update_posting(
|
||||||
|
db: Session,
|
||||||
|
month: Month,
|
||||||
|
posting_id: int,
|
||||||
|
*,
|
||||||
|
occurred_on: date | None = None,
|
||||||
|
amount: Decimal | None = None,
|
||||||
|
description: str | None | object = _POSTING_SENTINEL,
|
||||||
|
payee: str | None | object = _POSTING_SENTINEL,
|
||||||
|
) -> Posting | None:
|
||||||
|
posting = get_posting(db, month, posting_id)
|
||||||
|
if posting is None:
|
||||||
|
return None
|
||||||
|
if occurred_on is not None:
|
||||||
|
posting.occurred_on = occurred_on
|
||||||
|
if amount is not None:
|
||||||
|
posting.amount = amount
|
||||||
|
if description is not _POSTING_SENTINEL:
|
||||||
|
posting.description = _clean_notes(description) # type: ignore[arg-type]
|
||||||
|
if payee is not _POSTING_SENTINEL:
|
||||||
|
posting.payee = _clean_notes(payee) # type: ignore[arg-type]
|
||||||
|
db.commit()
|
||||||
|
db.refresh(posting)
|
||||||
|
return posting
|
||||||
|
|
||||||
|
|
||||||
|
def delete_posting(
|
||||||
|
db: Session, month: Month, posting_id: int
|
||||||
|
) -> MonthEntry | None:
|
||||||
|
"""Delete a posting; return the parent entry so callers can re-render its section."""
|
||||||
|
posting = get_posting(db, month, posting_id)
|
||||||
|
if posting is None:
|
||||||
|
return None
|
||||||
|
entry = posting.entry
|
||||||
|
db.delete(posting)
|
||||||
|
db.commit()
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
def get_month_target(db: Session, month: Month) -> MonthDebtTarget:
|
def get_month_target(db: Session, month: Month) -> MonthDebtTarget:
|
||||||
if month.target is not None:
|
if month.target is not None:
|
||||||
return month.target
|
return month.target
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||||
|
|
@ -242,7 +243,6 @@ def update_month_entry(
|
||||||
request: Request,
|
request: Request,
|
||||||
name: str | None = Form(None),
|
name: str | None = Form(None),
|
||||||
planned: str | None = Form(None),
|
planned: str | None = Form(None),
|
||||||
applied: str | None = Form(None),
|
|
||||||
notes: str | None = Form(None),
|
notes: str | None = Form(None),
|
||||||
db: Session = Depends(get_session),
|
db: Session = Depends(get_session),
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
|
|
@ -253,11 +253,9 @@ def update_month_entry(
|
||||||
if not clean_name:
|
if not clean_name:
|
||||||
raise HTTPException(status_code=400, detail="name must not be empty")
|
raise HTTPException(status_code=400, detail="name must not be empty")
|
||||||
parsed_planned = _parse_amount(planned) if planned is not None else None
|
parsed_planned = _parse_amount(planned) if planned is not None else None
|
||||||
parsed_applied = _parse_amount(applied) if applied is not None else None
|
|
||||||
kwargs: dict = dict(
|
kwargs: dict = dict(
|
||||||
name=clean_name,
|
name=clean_name,
|
||||||
planned=parsed_planned,
|
planned=parsed_planned,
|
||||||
applied=parsed_applied,
|
|
||||||
)
|
)
|
||||||
if notes is not None:
|
if notes is not None:
|
||||||
kwargs["notes"] = notes
|
kwargs["notes"] = notes
|
||||||
|
|
@ -320,6 +318,109 @@ def reopen_month(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(raw: str) -> date:
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(raw.strip())
|
||||||
|
except (ValueError, AttributeError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="date must be YYYY-MM-DD"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_signed_amount(raw: str) -> Decimal:
|
||||||
|
try:
|
||||||
|
amount = Decimal(raw.strip())
|
||||||
|
except (InvalidOperation, AttributeError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="amount must be numeric"
|
||||||
|
) from exc
|
||||||
|
return amount.quantize(Decimal("0.01"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/entries/{entry_id}/postings", response_class=HTMLResponse)
|
||||||
|
def create_posting(
|
||||||
|
year_month: str,
|
||||||
|
entry_id: int,
|
||||||
|
request: Request,
|
||||||
|
occurred_on: str = Form(...),
|
||||||
|
amount: str = Form(...),
|
||||||
|
description: str | None = Form(None),
|
||||||
|
payee: str | None = Form(None),
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
month = _require_editable_month(db, year_month)
|
||||||
|
posting = month_service.add_posting(
|
||||||
|
db,
|
||||||
|
month,
|
||||||
|
entry_id,
|
||||||
|
_parse_date(occurred_on),
|
||||||
|
_parse_signed_amount(amount),
|
||||||
|
description=description,
|
||||||
|
payee=payee,
|
||||||
|
)
|
||||||
|
if posting is None:
|
||||||
|
raise HTTPException(status_code=404, detail="entry not found")
|
||||||
|
db.refresh(month)
|
||||||
|
entry = month_service.get_month_entry(db, month, entry_id)
|
||||||
|
return _append_oob(
|
||||||
|
_render_section(request, month, entry.section),
|
||||||
|
_render_zero(request, month),
|
||||||
|
_render_group_totals(request, month),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/postings/{posting_id}", response_class=HTMLResponse)
|
||||||
|
def update_posting(
|
||||||
|
year_month: str,
|
||||||
|
posting_id: int,
|
||||||
|
request: Request,
|
||||||
|
occurred_on: str | None = Form(None),
|
||||||
|
amount: str | None = Form(None),
|
||||||
|
description: str | None = Form(None),
|
||||||
|
payee: str | None = Form(None),
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
month = _require_editable_month(db, year_month)
|
||||||
|
kwargs: dict = {}
|
||||||
|
if occurred_on is not None:
|
||||||
|
kwargs["occurred_on"] = _parse_date(occurred_on)
|
||||||
|
if amount is not None:
|
||||||
|
kwargs["amount"] = _parse_signed_amount(amount)
|
||||||
|
if description is not None:
|
||||||
|
kwargs["description"] = description
|
||||||
|
if payee is not None:
|
||||||
|
kwargs["payee"] = payee
|
||||||
|
updated = month_service.update_posting(db, month, posting_id, **kwargs)
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=404, detail="posting not found")
|
||||||
|
db.refresh(month)
|
||||||
|
entry = updated.entry
|
||||||
|
return _append_oob(
|
||||||
|
_render_section(request, month, entry.section),
|
||||||
|
_render_zero(request, month),
|
||||||
|
_render_group_totals(request, month),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/postings/{posting_id}", response_class=HTMLResponse)
|
||||||
|
def delete_posting(
|
||||||
|
year_month: str,
|
||||||
|
posting_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
month = _require_editable_month(db, year_month)
|
||||||
|
entry = month_service.delete_posting(db, month, posting_id)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=404, detail="posting not found")
|
||||||
|
db.refresh(month)
|
||||||
|
return _append_oob(
|
||||||
|
_render_section(request, month, entry.section),
|
||||||
|
_render_zero(request, month),
|
||||||
|
_render_group_totals(request, month),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/target", response_class=HTMLResponse)
|
@router.post("/target", response_class=HTMLResponse)
|
||||||
def update_month_target(
|
def update_month_target(
|
||||||
year_month: str,
|
year_month: str,
|
||||||
|
|
|
||||||
|
|
@ -421,6 +421,40 @@ tr.add-row td {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Add-entry disclosure: collapsed trigger, expanded form */
|
||||||
|
details.add-entry { margin: 0; }
|
||||||
|
details.add-entry > summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.25rem;
|
||||||
|
}
|
||||||
|
details.add-entry > summary::-webkit-details-marker { display: none; }
|
||||||
|
details.add-entry .add-trigger {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px dashed transparent;
|
||||||
|
padding-bottom: 1px;
|
||||||
|
transition: color 0.12s ease, border-color 0.12s ease;
|
||||||
|
}
|
||||||
|
details.add-entry > summary:hover .add-trigger {
|
||||||
|
color: var(--ink);
|
||||||
|
border-bottom-color: var(--rule);
|
||||||
|
}
|
||||||
|
details.add-entry[open] > summary .add-trigger {
|
||||||
|
color: var(--ink);
|
||||||
|
border-bottom-color: var(--ink);
|
||||||
|
}
|
||||||
|
details.add-entry > .add-form,
|
||||||
|
details.add-entry > .month-add-form {
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
.entry-name {
|
.entry-name {
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
@ -523,11 +557,10 @@ tr.entry-notes-row input.notes-input::placeholder {
|
||||||
color: var(--rule);
|
color: var(--rule);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
tr.entry-notes-row:has(input:placeholder-shown) { display: none; }
|
/* Empty notes render subtly (placeholder only) so they stay clickable. */
|
||||||
tr.entry:hover + tr.entry-notes-row:has(input:placeholder-shown) {
|
tr.entry-notes-row:has(input:placeholder-shown) { opacity: 0.55; }
|
||||||
display: block;
|
tr.entry-notes-row:hover,
|
||||||
opacity: 0.5;
|
tr.entry-notes-row:has(input:focus) { opacity: 1; }
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
|
|
@ -675,6 +708,300 @@ tr.entry:hover + tr.entry-notes-row:has(input:placeholder-shown) {
|
||||||
padding: 0.15rem 0.4rem;
|
padding: 0.15rem 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============== MONTH ENTRIES — details-based layout =============== */
|
||||||
|
|
||||||
|
.month-entries {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-block {
|
||||||
|
border-bottom: 1px dotted var(--rule);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-block > summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.9rem minmax(0, 1fr) 5.5rem 9rem 1.2rem;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 0.32rem 0.25rem 0.36rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.entry-block > summary::-webkit-details-marker { display: none; }
|
||||||
|
.entry-block > summary:hover { background: var(--paper-stripe); }
|
||||||
|
|
||||||
|
/* Progress bar on the summary row */
|
||||||
|
.entry-block > summary::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0; right: 0; bottom: -1px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--sage-soft);
|
||||||
|
width: min(100%, calc(var(--ratio, 1) * 100%));
|
||||||
|
transition: width 0.25s ease;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.entry-block > summary.state-edited::after { background: var(--accent); opacity: 0.85; }
|
||||||
|
.entry-block > summary.state-new_in_month::after { background: var(--indigo); opacity: 0.55; }
|
||||||
|
|
||||||
|
/* Caret: rotates on [open] */
|
||||||
|
.entry-block .caret {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.7rem;
|
||||||
|
position: relative;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.entry-block .caret::before {
|
||||||
|
content: "▸";
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--muted);
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.entry-block[open] > summary .caret { transform: rotate(90deg); }
|
||||||
|
|
||||||
|
/* Entry row cells */
|
||||||
|
.entry-block .entry-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.entry-block .entry-name input,
|
||||||
|
.entry-block .entry-amount input {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
.entry-block .entry-name input:hover,
|
||||||
|
.entry-block .entry-amount input:hover { border-bottom-color: var(--rule); }
|
||||||
|
.entry-block .entry-name input:focus,
|
||||||
|
.entry-block .entry-amount input:focus { border-bottom-color: var(--ink); }
|
||||||
|
|
||||||
|
.entry-block .entry-amount {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: right;
|
||||||
|
font-feature-settings: "lnum" 1, "tnum" 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.entry-block .entry-amount input {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.entry-block .entry-amount.planned input { color: var(--muted); font-weight: 400; }
|
||||||
|
|
||||||
|
.entry-block .applied-cell {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.entry-block .applied-cell .value {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.entry-block .applied-cell .count {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: lowercase;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-block .entry-actions button.delete {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--rule);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: color 0.12s ease, opacity 0.12s ease;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.entry-block > summary:hover .entry-actions button.delete { opacity: 1; }
|
||||||
|
.entry-block .entry-actions button.delete:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Expanded body */
|
||||||
|
.entry-block .entry-body {
|
||||||
|
padding: 0.5rem 1.6rem 0.75rem;
|
||||||
|
background: var(--paper-soft);
|
||||||
|
border-top: 1px solid var(--rule-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-block .entry-notes {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.entry-block .entry-notes input {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ink);
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px dashed var(--rule);
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.entry-block .entry-notes input:focus { border-bottom-color: var(--ink); }
|
||||||
|
.entry-block .entry-notes.readonly {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transactions table */
|
||||||
|
table.transactions {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
table.transactions thead th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0.2rem 0.3rem 0.25rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
table.transactions th.col-amount,
|
||||||
|
table.transactions td.col-amount { text-align: right; width: 5.5rem; }
|
||||||
|
table.transactions th.col-date,
|
||||||
|
table.transactions td.col-date { width: 7.5rem; }
|
||||||
|
table.transactions th.col-actions,
|
||||||
|
table.transactions td.col-actions { width: 1.2rem; }
|
||||||
|
|
||||||
|
table.transactions td {
|
||||||
|
padding: 0.2rem 0.3rem;
|
||||||
|
border-bottom: 1px dotted var(--rule-soft);
|
||||||
|
}
|
||||||
|
table.transactions tr.posting:hover { background: var(--paper); }
|
||||||
|
table.transactions input {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
table.transactions input:hover { border-bottom-color: var(--rule); }
|
||||||
|
table.transactions input:focus { border-bottom-color: var(--ink); }
|
||||||
|
table.transactions input[type="number"] {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
table.transactions input[type="date"] {
|
||||||
|
font-family: var(--sans);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
table.transactions tr.empty td {
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.transactions td .readonly {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.transactions button.delete {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--rule);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: color 0.12s ease, opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
table.transactions tr:hover button.delete { opacity: 1; }
|
||||||
|
table.transactions button.delete:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Add-posting form */
|
||||||
|
form.add-posting-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 7.5rem minmax(0, 1fr) minmax(0, 1fr) 5.5rem auto;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.35rem 0.3rem 0.2rem;
|
||||||
|
border-top: 1px dashed var(--rule);
|
||||||
|
}
|
||||||
|
form.add-posting-form input {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
form.add-posting-form input[type="number"] { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
form.add-posting-form input:focus { border-color: var(--ink); }
|
||||||
|
form.add-posting-form button[type="submit"] {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border: 1px solid var(--ink);
|
||||||
|
background: var(--paper-soft);
|
||||||
|
color: var(--ink);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease, color 0.12s ease;
|
||||||
|
}
|
||||||
|
form.add-posting-form button[type="submit"]:hover { background: var(--ink); color: var(--paper); }
|
||||||
|
|
||||||
|
.empty-row {
|
||||||
|
padding: 0.5rem 0.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.entry-block > summary {
|
||||||
|
grid-template-columns: 0.9rem minmax(0, 1fr) 4.2rem 7rem 1rem;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.entry-block .entry-body { padding: 0.5rem 0.75rem 0.6rem; }
|
||||||
|
.entry-block .applied-cell .count { font-size: 0.58rem; letter-spacing: 0.1em; }
|
||||||
|
form.add-posting-form {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
form.add-posting-form input[type="date"],
|
||||||
|
form.add-posting-form input[type="number"],
|
||||||
|
form.add-posting-form button[type="submit"] { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Disabled inputs (closed month) */
|
/* Disabled inputs (closed month) */
|
||||||
input[disabled],
|
input[disabled],
|
||||||
select[disabled],
|
select[disabled],
|
||||||
|
|
|
||||||
|
|
@ -2,68 +2,61 @@
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>{{ section.label }}</h2>
|
<h2>{{ section.label }}</h2>
|
||||||
<span class="total" data-testid="total-{{ section.section.value }}">
|
<span class="total" data-testid="total-{{ section.section.value }}">
|
||||||
<span class="applied">${{ '%.2f' | format(section.total_applied) }}</span>
|
<span class="applied">${{ '{:,.2f}'.format(section.total_applied) }}</span>
|
||||||
<span class="divider">/</span>
|
<span class="divider">/</span>
|
||||||
<span class="planned">${{ '%.2f' | format(section.total_planned) }}</span>
|
<span class="planned">${{ '{:,.2f}'.format(section.total_planned) }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<table class="entries month-entries">
|
<div class="entries month-entries">
|
||||||
<thead>
|
{% for row in section.rows %}
|
||||||
<tr>
|
{% set applied = row.entry.applied %}
|
||||||
<th class="col-name">Name</th>
|
<details class="entry-block" data-entry-id="{{ row.entry.id }}">
|
||||||
<th class="col-planned">Planned</th>
|
<summary class="entry-row state-{{ row.state.value }}" style="--ratio: {{ (applied / row.entry.planned)|round(4) if row.entry.planned > 0 else 0 }}">
|
||||||
<th class="col-applied">Applied</th>
|
<span class="caret" aria-hidden="true"></span>
|
||||||
<th class="col-actions"></th>
|
<span class="entry-name">
|
||||||
</tr>
|
{% if editable %}
|
||||||
</thead>
|
<input
|
||||||
<tbody>
|
type="text"
|
||||||
{% for row in section.rows %}
|
name="name"
|
||||||
<tr class="entry state-{{ row.state.value }}" data-entry-id="{{ row.entry.id }}">
|
value="{{ row.entry.name }}"
|
||||||
<td class="entry-name">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
value="{{ row.entry.name }}"
|
|
||||||
{% if editable %}
|
|
||||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-target="#section-{{ section.section.value }}"
|
hx-target="#section-{{ section.section.value }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
{% else %}disabled{% endif %}
|
aria-label="Name"
|
||||||
>
|
>
|
||||||
|
{% else %}
|
||||||
|
<span class="readonly">{{ row.entry.name }}</span>
|
||||||
|
{% endif %}
|
||||||
{% if row.state.value == 'edited' %}
|
{% if row.state.value == 'edited' %}
|
||||||
<span class="tag tag-edited">modified</span>
|
<span class="tag tag-edited">modified</span>
|
||||||
{% elif row.state.value == 'new_in_month' %}
|
{% elif row.state.value == 'new_in_month' %}
|
||||||
<span class="tag tag-new">new this month</span>
|
<span class="tag tag-new">new this month</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</span>
|
||||||
<td class="entry-amount">
|
<span class="entry-amount planned">
|
||||||
<input
|
{% if editable %}
|
||||||
type="number" step="0.01" min="0"
|
<input
|
||||||
name="planned"
|
type="number" step="0.01" min="0"
|
||||||
value="{{ '%.2f' | format(row.entry.planned) }}"
|
name="planned"
|
||||||
{% if editable %}
|
value="{{ '%.2f' | format(row.entry.planned) }}"
|
||||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-target="#section-{{ section.section.value }}"
|
hx-target="#section-{{ section.section.value }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
{% else %}disabled{% endif %}
|
aria-label="Planned"
|
||||||
>
|
>
|
||||||
</td>
|
{% else %}
|
||||||
<td class="entry-amount">
|
<span class="readonly">${{ '{:,.2f}'.format(row.entry.planned) }}</span>
|
||||||
<input
|
{% endif %}
|
||||||
type="number" step="0.01" min="0"
|
</span>
|
||||||
name="applied"
|
<span class="entry-amount applied-cell" aria-label="Applied">
|
||||||
value="{{ '%.2f' | format(row.entry.applied) }}"
|
<span class="value">${{ '{:,.2f}'.format(applied) }}</span>
|
||||||
{% if editable %}
|
{% if row.entry.postings|length > 0 %}
|
||||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
<span class="count">· {{ row.entry.postings|length }} txn{% if row.entry.postings|length != 1 %}s{% endif %}</span>
|
||||||
hx-trigger="change"
|
{% endif %}
|
||||||
hx-target="#section-{{ section.section.value }}"
|
</span>
|
||||||
hx-swap="outerHTML"
|
<span class="entry-actions">
|
||||||
{% else %}disabled{% endif %}
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td class="entry-actions">
|
|
||||||
{% if editable %}
|
{% if editable %}
|
||||||
<button
|
<button
|
||||||
class="delete"
|
class="delete"
|
||||||
|
|
@ -74,47 +67,158 @@
|
||||||
aria-label="Delete {{ row.entry.name }}"
|
aria-label="Delete {{ row.entry.name }}"
|
||||||
>×</button>
|
>×</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</span>
|
||||||
</tr>
|
</summary>
|
||||||
<tr class="entry-notes-row">
|
<div class="entry-body">
|
||||||
<td colspan="4">
|
{% if editable %}
|
||||||
<input
|
<div class="entry-notes">
|
||||||
class="notes-input"
|
<input
|
||||||
type="text"
|
class="notes-input"
|
||||||
name="notes"
|
type="text"
|
||||||
value="{{ row.entry.notes or '' }}"
|
name="notes"
|
||||||
placeholder="notes..."
|
value="{{ row.entry.notes or '' }}"
|
||||||
{% if editable %}
|
placeholder="notes for this entry..."
|
||||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-target="#section-{{ section.section.value }}"
|
hx-target="#section-{{ section.section.value }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
aria-label="Notes for {{ row.entry.name }}"
|
aria-label="Notes"
|
||||||
{% else %}disabled{% endif %}
|
>
|
||||||
>
|
</div>
|
||||||
</td>
|
{% elif row.entry.notes %}
|
||||||
</tr>
|
<div class="entry-notes readonly">{{ row.entry.notes }}</div>
|
||||||
{% else %}
|
{% endif %}
|
||||||
<tr class="empty"><td colspan="4">No entries.</td></tr>
|
<table class="transactions">
|
||||||
{% endfor %}
|
<thead>
|
||||||
{% if editable %}
|
<tr>
|
||||||
<tr class="add-row">
|
<th class="col-date">Date</th>
|
||||||
<td colspan="4">
|
<th class="col-desc">Description</th>
|
||||||
|
<th class="col-payee">Payee</th>
|
||||||
|
<th class="col-amount">Amount</th>
|
||||||
|
<th class="col-actions"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for posting in row.entry.postings %}
|
||||||
|
<tr class="posting" data-posting-id="{{ posting.id }}">
|
||||||
|
<td class="col-date">
|
||||||
|
{% if editable %}
|
||||||
|
<input
|
||||||
|
type="date" name="occurred_on"
|
||||||
|
value="{{ posting.occurred_on.isoformat() }}"
|
||||||
|
hx-post="/month/{{ month.year_month }}/postings/{{ posting.id }}"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
aria-label="Date"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
|
<span class="readonly">{{ posting.occurred_on.isoformat() }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="col-desc">
|
||||||
|
{% if editable %}
|
||||||
|
<input
|
||||||
|
type="text" name="description"
|
||||||
|
value="{{ posting.description or '' }}"
|
||||||
|
placeholder="description"
|
||||||
|
hx-post="/month/{{ month.year_month }}/postings/{{ posting.id }}"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
aria-label="Description"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
|
<span class="readonly">{{ posting.description or '' }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="col-payee">
|
||||||
|
{% if editable %}
|
||||||
|
<input
|
||||||
|
type="text" name="payee"
|
||||||
|
value="{{ posting.payee or '' }}"
|
||||||
|
placeholder="payee"
|
||||||
|
hx-post="/month/{{ month.year_month }}/postings/{{ posting.id }}"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
aria-label="Payee"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
|
<span class="readonly">{{ posting.payee or '' }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="col-amount">
|
||||||
|
{% if editable %}
|
||||||
|
<input
|
||||||
|
type="number" step="0.01" name="amount"
|
||||||
|
value="{{ '%.2f' | format(posting.amount) }}"
|
||||||
|
hx-post="/month/{{ month.year_month }}/postings/{{ posting.id }}"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
aria-label="Amount"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
|
<span class="readonly">${{ '{:,.2f}'.format(posting.amount) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="col-actions">
|
||||||
|
{% if editable %}
|
||||||
|
<button
|
||||||
|
class="delete"
|
||||||
|
type="button"
|
||||||
|
hx-delete="/month/{{ month.year_month }}/postings/{{ posting.id }}"
|
||||||
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
aria-label="Delete transaction"
|
||||||
|
>×</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr class="empty"><td colspan="5">No transactions yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if editable %}
|
||||||
<form
|
<form
|
||||||
class="add-form month-add-form"
|
class="add-posting-form"
|
||||||
hx-post="/month/{{ month.year_month }}/sections/{{ section.section.value }}/entries"
|
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}/postings"
|
||||||
hx-target="#section-{{ section.section.value }}"
|
hx-target="#section-{{ section.section.value }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-on::after-request="if(event.detail.successful) this.reset()"
|
hx-on::after-request="if(event.detail.successful) this.reset()"
|
||||||
>
|
>
|
||||||
<input type="text" name="name" placeholder="Name" required>
|
<input type="date" name="occurred_on" required value="{{ month.year_month }}-01" aria-label="Date">
|
||||||
<input type="number" name="planned" step="0.01" min="0" placeholder="Planned" required>
|
<input type="text" name="description" placeholder="description" aria-label="Description">
|
||||||
<button type="submit">Add</button>
|
<input type="text" name="payee" placeholder="payee" aria-label="Payee">
|
||||||
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
<input type="number" step="0.01" name="amount" placeholder="0.00" required aria-label="Amount">
|
||||||
|
<button type="submit">Add transaction</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
{% endif %}
|
||||||
</tr>
|
</div>
|
||||||
{% endif %}
|
</details>
|
||||||
</tbody>
|
{% else %}
|
||||||
</table>
|
<div class="empty-row">No entries.</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if editable %}
|
||||||
|
<div class="add-row">
|
||||||
|
<details class="add-entry">
|
||||||
|
<summary><span class="add-trigger">+ add {{ section.label|lower }}</span></summary>
|
||||||
|
<form
|
||||||
|
class="add-form month-add-form"
|
||||||
|
hx-post="/month/{{ month.year_month }}/sections/{{ section.section.value }}/entries"
|
||||||
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-on::after-request="if(event.detail.successful) this.reset()"
|
||||||
|
>
|
||||||
|
<input type="text" name="name" placeholder="Name" required>
|
||||||
|
<input type="number" name="planned" step="0.01" min="0" placeholder="Planned" required>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -44,18 +44,21 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<tr class="add-row">
|
<tr class="add-row">
|
||||||
<td colspan="4">
|
<td colspan="4">
|
||||||
<form
|
<details class="add-entry">
|
||||||
class="add-form"
|
<summary><span class="add-trigger">+ add {{ section.label|lower }}</span></summary>
|
||||||
hx-post="/sections/{{ section.section.value }}/entries"
|
<form
|
||||||
hx-target="#section-{{ section.section.value }}"
|
class="add-form"
|
||||||
hx-swap="outerHTML"
|
hx-post="/sections/{{ section.section.value }}/entries"
|
||||||
hx-on::after-request="if(event.detail.successful) this.reset()"
|
hx-target="#section-{{ section.section.value }}"
|
||||||
>
|
hx-swap="outerHTML"
|
||||||
<input type="text" name="name" placeholder="Name" required>
|
hx-on::after-request="if(event.detail.successful) this.reset()"
|
||||||
<input type="number" name="amount" step="0.01" min="0" placeholder="0.00" required>
|
>
|
||||||
<button type="submit">Add</button>
|
<input type="text" name="name" placeholder="Name" required>
|
||||||
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
<input type="number" name="amount" step="0.01" min="0" placeholder="0.00" required>
|
||||||
</form>
|
<button type="submit">Add</button>
|
||||||
|
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from quartermaster import month_service, service
|
from quartermaster import month_service, service
|
||||||
|
|
@ -65,10 +66,10 @@ def test_month_group_views_planned_and_applied(db):
|
||||||
service.add_entry(db, Section.fixed_bill, "Rent", Decimal("1200.00"))
|
service.add_entry(db, Section.fixed_bill, "Rent", Decimal("1200.00"))
|
||||||
service.add_entry(db, Section.sinking_fund, "Emergency", Decimal("300.00"))
|
service.add_entry(db, Section.sinking_fund, "Emergency", Decimal("300.00"))
|
||||||
month = month_service.create_month(db, "2026-04")
|
month = month_service.create_month(db, "2026-04")
|
||||||
# seed applied values
|
# seed applied values via a posting
|
||||||
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
month_service.update_month_entry(
|
month_service.add_posting(
|
||||||
db, month, rent.id, applied=Decimal("1200.00")
|
db, month, rent.id, date.today(), Decimal("1200.00"), description="test"
|
||||||
)
|
)
|
||||||
db.refresh(month)
|
db.refresh(month)
|
||||||
views = {v.group: v for v in month_service.month_group_views(month)}
|
views = {v.group: v for v in month_service.month_group_views(month)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -16,10 +17,10 @@ def _seed(db, balance_to_zero=False):
|
||||||
service.add_entry(db, Section.other, "Misc", Decimal("60.00"))
|
service.add_entry(db, Section.other, "Misc", Decimal("60.00"))
|
||||||
month = month_service.create_month(db, "2026-04")
|
month = month_service.create_month(db, "2026-04")
|
||||||
if balance_to_zero:
|
if balance_to_zero:
|
||||||
# Apply income fully and distribute applied to match planned exactly
|
# Post one transaction per entry matching the planned amount
|
||||||
for entry in month.entries:
|
for entry in month.entries:
|
||||||
month_service.update_month_entry(
|
month_service.add_posting(
|
||||||
db, month, entry.id, applied=entry.planned
|
db, month, entry.id, date.today(), entry.planned, description="seed"
|
||||||
)
|
)
|
||||||
db.refresh(month)
|
db.refresh(month)
|
||||||
return month
|
return month
|
||||||
|
|
@ -61,8 +62,8 @@ def test_close_requires_zero_balance(db):
|
||||||
# applied zero is 0 - 0 = 0, but income applied is 0 so
|
# applied zero is 0 - 0 = 0, but income applied is 0 so
|
||||||
# (0 income - 0 others) == 0 actually. Force a non-zero by applying to food.
|
# (0 income - 0 others) == 0 actually. Force a non-zero by applying to food.
|
||||||
food = next(e for e in month.entries if e.origin_name == "Groceries")
|
food = next(e for e in month.entries if e.origin_name == "Groceries")
|
||||||
month_service.update_month_entry(
|
month_service.add_posting(
|
||||||
db, month, food.id, applied=Decimal("50.00")
|
db, month, food.id, date.today(), Decimal("50.00"), description="seed"
|
||||||
)
|
)
|
||||||
db.refresh(month)
|
db.refresh(month)
|
||||||
with pytest.raises(month_service.MonthLifecycleError) as exc:
|
with pytest.raises(month_service.MonthLifecycleError) as exc:
|
||||||
|
|
@ -128,13 +129,16 @@ def _seed_via_api(client, balance_to_zero=False):
|
||||||
if balance_to_zero:
|
if balance_to_zero:
|
||||||
# entry ids 1,2,3 in that order
|
# entry ids 1,2,3 in that order
|
||||||
client.post(
|
client.post(
|
||||||
"/month/2026-04/entries/1", data={"applied": "1000.00"}
|
"/month/2026-04/entries/1/postings",
|
||||||
|
data={"occurred_on": "2026-04-01", "amount": "1000.00"},
|
||||||
)
|
)
|
||||||
client.post(
|
client.post(
|
||||||
"/month/2026-04/entries/2", data={"applied": "600.00"}
|
"/month/2026-04/entries/2/postings",
|
||||||
|
data={"occurred_on": "2026-04-01", "amount": "600.00"},
|
||||||
)
|
)
|
||||||
client.post(
|
client.post(
|
||||||
"/month/2026-04/entries/3", data={"applied": "400.00"}
|
"/month/2026-04/entries/3/postings",
|
||||||
|
data={"occurred_on": "2026-04-15", "amount": "400.00"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -148,8 +152,11 @@ def test_activate_route(client):
|
||||||
def test_close_route_rejects_unbalanced(client):
|
def test_close_route_rejects_unbalanced(client):
|
||||||
_seed_via_api(client)
|
_seed_via_api(client)
|
||||||
client.post("/month/2026-04/activate")
|
client.post("/month/2026-04/activate")
|
||||||
# apply some expense without income to produce an unbalanced state
|
# post an expense without income to produce an unbalanced state
|
||||||
client.post("/month/2026-04/entries/2", data={"applied": "100.00"})
|
client.post(
|
||||||
|
"/month/2026-04/entries/2/postings",
|
||||||
|
data={"occurred_on": "2026-04-01", "amount": "100.00"},
|
||||||
|
)
|
||||||
response = client.post("/month/2026-04/close")
|
response = client.post("/month/2026-04/close")
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "0.00" in response.json()["detail"]
|
assert "0.00" in response.json()["detail"]
|
||||||
|
|
@ -182,7 +189,8 @@ def test_mutations_rejected_on_closed_month(client):
|
||||||
)
|
)
|
||||||
assert add_response.status_code == 400
|
assert add_response.status_code == 400
|
||||||
update_response = client.post(
|
update_response = client.post(
|
||||||
"/month/2026-04/entries/1", data={"applied": "1200.00"}
|
"/month/2026-04/entries/1/postings",
|
||||||
|
data={"occurred_on": "2026-04-15", "amount": "50.00"},
|
||||||
)
|
)
|
||||||
assert update_response.status_code == 400
|
assert update_response.status_code == 400
|
||||||
delete_response = client.delete("/month/2026-04/entries/1")
|
delete_response = client.delete("/month/2026-04/entries/1")
|
||||||
|
|
@ -201,8 +209,11 @@ def test_month_page_shows_planning_badge_and_activate_button(client):
|
||||||
def test_month_page_shows_active_badge_with_disabled_close_when_unbalanced(client):
|
def test_month_page_shows_active_badge_with_disabled_close_when_unbalanced(client):
|
||||||
_seed_via_api(client)
|
_seed_via_api(client)
|
||||||
client.post("/month/2026-04/activate")
|
client.post("/month/2026-04/activate")
|
||||||
# force imbalance
|
# force imbalance via a posting
|
||||||
client.post("/month/2026-04/entries/2", data={"applied": "100.00"})
|
client.post(
|
||||||
|
"/month/2026-04/entries/2/postings",
|
||||||
|
data={"occurred_on": "2026-04-01", "amount": "100.00"},
|
||||||
|
)
|
||||||
response = client.get("/month/2026-04")
|
response = client.get("/month/2026-04")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "state-active" in response.text
|
assert "state-active" in response.text
|
||||||
|
|
@ -237,7 +248,10 @@ def test_closed_month_inputs_are_disabled(client):
|
||||||
client.post("/month/2026-04/activate")
|
client.post("/month/2026-04/activate")
|
||||||
client.post("/month/2026-04/close")
|
client.post("/month/2026-04/close")
|
||||||
response = client.get("/month/2026-04")
|
response = client.get("/month/2026-04")
|
||||||
# name inputs rendered with disabled
|
# Editable inputs are replaced by readonly spans on closed months,
|
||||||
assert "disabled" in response.text
|
# so the name/planned inputs should not appear.
|
||||||
|
assert 'name="planned"' not in response.text
|
||||||
|
assert 'name="name"' not in response.text
|
||||||
|
assert "class=\"readonly\"" in response.text
|
||||||
# delete buttons not rendered
|
# delete buttons not rendered
|
||||||
assert "Delete Paycheck" not in response.text
|
assert "Delete Paycheck" not in response.text
|
||||||
|
|
|
||||||
|
|
@ -38,23 +38,24 @@ def test_created_month_renders_snapshot(client):
|
||||||
assert "Paycheck" in response.text
|
assert "Paycheck" in response.text
|
||||||
assert "Rent" in response.text
|
assert "Rent" in response.text
|
||||||
assert "Card A" in response.text
|
assert "Card A" in response.text
|
||||||
# totals: applied / planned
|
# totals: applied / planned rendered with thousands separators
|
||||||
assert "$2500.00" in response.text
|
assert "$2,500.00" in response.text
|
||||||
assert "$1200.00" in response.text
|
assert "$1,200.00" in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_applied_update_returns_section_partial(client):
|
def test_applied_update_returns_section_partial(client):
|
||||||
_seed_budget_via_api(client)
|
_seed_budget_via_api(client)
|
||||||
client.post("/month/2026-04/create")
|
client.post("/month/2026-04/create")
|
||||||
# fetch month page, identify rent month_entry id = 2 (after income id 1)
|
# rent is month_entry id=2 (after income id=1). Post a transaction so
|
||||||
|
# applied accumulates to 1200.
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/month/2026-04/entries/2", data={"applied": "1200.00"}
|
"/month/2026-04/entries/2/postings",
|
||||||
|
data={"occurred_on": "2026-04-01", "amount": "1200.00"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
# rent is in fixed_bill section; total_applied should now include 1200
|
# rent is in fixed_bill section; total_applied should now include 1200
|
||||||
assert 'id="section-fixed_bill"' in response.text
|
assert 'id="section-fixed_bill"' in response.text
|
||||||
# two $1200.00 occurrences: total_applied and planned (same amount)
|
assert "$1,200.00" in response.text or "$1200.00" in response.text
|
||||||
assert response.text.count("$1200.00") >= 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_name_edit_flips_to_modified(client):
|
def test_name_edit_flips_to_modified(client):
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -8,6 +9,13 @@ from quartermaster import month_service, service
|
||||||
from quartermaster.models import Section
|
from quartermaster.models import Section
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(db, month, entry_id, amount):
|
||||||
|
"""Record an applied amount against a month entry by posting."""
|
||||||
|
return month_service.add_posting(
|
||||||
|
db, month, entry_id, date.today(), Decimal(str(amount)), description="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _seed_budget(db):
|
def _seed_budget(db):
|
||||||
income = service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00"))
|
income = service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00"))
|
||||||
rent = service.add_entry(
|
rent = service.add_entry(
|
||||||
|
|
@ -96,9 +104,8 @@ def test_applied_update_changes_totals(db):
|
||||||
_seed_budget(db)
|
_seed_budget(db)
|
||||||
month = month_service.create_month(db, "2026-04")
|
month = month_service.create_month(db, "2026-04")
|
||||||
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
month_service.update_month_entry(
|
_apply(db, month, rent.id, "1200.00")
|
||||||
db, month, rent.id, applied=Decimal("1200.00")
|
db.refresh(month)
|
||||||
)
|
|
||||||
view = month_service.section_view(month, Section.fixed_bill, "Fixed")
|
view = month_service.section_view(month, Section.fixed_bill, "Fixed")
|
||||||
assert view.total_applied == Decimal("1200.00")
|
assert view.total_applied == Decimal("1200.00")
|
||||||
assert view.total_planned == Decimal("1200.00")
|
assert view.total_planned == Decimal("1200.00")
|
||||||
|
|
|
||||||
|
|
@ -155,5 +155,6 @@ def test_month_page_renders_notes_inputs(client):
|
||||||
client.post("/month/2026-04/create")
|
client.post("/month/2026-04/create")
|
||||||
response = client.get("/month/2026-04")
|
response = client.get("/month/2026-04")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "entry-notes-row" in response.text
|
# notes input lives inside the expandable entry body now
|
||||||
|
assert 'name="notes"' in response.text
|
||||||
assert "auto-pay" in response.text
|
assert "auto-pay" in response.text
|
||||||
|
|
|
||||||
226
tests/test_postings.py
Normal file
226
tests/test_postings.py
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from quartermaster import month_service, service
|
||||||
|
from quartermaster.models import Posting, Section
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_month(db, section=Section.fixed_bill, name="Rent", planned="1200"):
|
||||||
|
service.add_entry(db, Section.income, "Paycheck", Decimal("2500"))
|
||||||
|
service.add_entry(db, section, name, Decimal(planned))
|
||||||
|
return month_service.create_month(db, "2026-04")
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_posting_sums_into_applied(db):
|
||||||
|
month = _seed_month(db)
|
||||||
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
month_service.add_posting(
|
||||||
|
db, month, rent.id, date(2026, 4, 1), Decimal("600"), description="first half"
|
||||||
|
)
|
||||||
|
month_service.add_posting(
|
||||||
|
db, month, rent.id, date(2026, 4, 15), Decimal("600"), description="second half"
|
||||||
|
)
|
||||||
|
db.refresh(rent)
|
||||||
|
assert rent.applied == Decimal("1200.00")
|
||||||
|
assert len(rent.postings) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_entry_applied_is_zero(db):
|
||||||
|
month = _seed_month(db)
|
||||||
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
assert rent.applied == Decimal("0.00")
|
||||||
|
assert rent.postings == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative_posting_allowed(db):
|
||||||
|
month = _seed_month(db)
|
||||||
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
month_service.add_posting(
|
||||||
|
db, month, rent.id, date(2026, 4, 1), Decimal("1200")
|
||||||
|
)
|
||||||
|
month_service.add_posting(
|
||||||
|
db, month, rent.id, date(2026, 4, 3), Decimal("-50"), description="refund"
|
||||||
|
)
|
||||||
|
db.refresh(rent)
|
||||||
|
assert rent.applied == Decimal("1150.00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_posting(db):
|
||||||
|
month = _seed_month(db)
|
||||||
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
posting = month_service.add_posting(
|
||||||
|
db, month, rent.id, date(2026, 4, 1), Decimal("1100")
|
||||||
|
)
|
||||||
|
month_service.update_posting(
|
||||||
|
db, month, posting.id,
|
||||||
|
amount=Decimal("1200"),
|
||||||
|
description="corrected",
|
||||||
|
occurred_on=date(2026, 4, 3),
|
||||||
|
)
|
||||||
|
db.refresh(rent)
|
||||||
|
assert rent.applied == Decimal("1200.00")
|
||||||
|
updated = rent.postings[0]
|
||||||
|
assert updated.description == "corrected"
|
||||||
|
assert updated.occurred_on == date(2026, 4, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_posting(db):
|
||||||
|
month = _seed_month(db)
|
||||||
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
p1 = month_service.add_posting(db, month, rent.id, date.today(), Decimal("600"))
|
||||||
|
p2 = month_service.add_posting(db, month, rent.id, date.today(), Decimal("600"))
|
||||||
|
entry = month_service.delete_posting(db, month, p1.id)
|
||||||
|
db.refresh(rent)
|
||||||
|
assert entry.id == rent.id
|
||||||
|
assert rent.applied == Decimal("600.00")
|
||||||
|
assert len(rent.postings) == 1
|
||||||
|
assert rent.postings[0].id == p2.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_entry_cascades_postings(db):
|
||||||
|
month = _seed_month(db)
|
||||||
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
month_service.add_posting(db, month, rent.id, date.today(), Decimal("600"))
|
||||||
|
month_service.add_posting(db, month, rent.id, date.today(), Decimal("600"))
|
||||||
|
# Postings count before deletion
|
||||||
|
all_postings = db.query(Posting).filter(Posting.month_entry_id == rent.id).count()
|
||||||
|
assert all_postings == 2
|
||||||
|
month_service.delete_month_entry(db, month, rent.id)
|
||||||
|
db.expire_all()
|
||||||
|
remaining = db.query(Posting).filter(Posting.month_entry_id == rent.id).count()
|
||||||
|
assert remaining == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_postings_ordered_desc(db):
|
||||||
|
month = _seed_month(db)
|
||||||
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
month_service.add_posting(db, month, rent.id, date(2026, 4, 1), Decimal("100"))
|
||||||
|
month_service.add_posting(db, month, rent.id, date(2026, 4, 15), Decimal("200"))
|
||||||
|
month_service.add_posting(db, month, rent.id, date(2026, 4, 8), Decimal("150"))
|
||||||
|
db.refresh(rent)
|
||||||
|
dates = [p.occurred_on for p in rent.postings]
|
||||||
|
assert dates == [date(2026, 4, 15), date(2026, 4, 8), date(2026, 4, 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_month_entry_rejects_applied_kwarg(db):
|
||||||
|
month = _seed_month(db)
|
||||||
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
month_service.update_month_entry(
|
||||||
|
db, month, rent.id, applied=Decimal("500")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----- Route-level tests -----
|
||||||
|
|
||||||
|
def _seed_via_api(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/income/entries",
|
||||||
|
data={"name": "Paycheck", "amount": "2500"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/sections/fixed_bill/entries",
|
||||||
|
data={"name": "Rent", "amount": "1200"},
|
||||||
|
)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_posting_route(client):
|
||||||
|
_seed_via_api(client)
|
||||||
|
response = client.post(
|
||||||
|
"/month/2026-04/entries/2/postings",
|
||||||
|
data={
|
||||||
|
"occurred_on": "2026-04-01",
|
||||||
|
"amount": "1200",
|
||||||
|
"description": "April rent",
|
||||||
|
"payee": "Landlord",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "April rent" in response.text
|
||||||
|
assert "Landlord" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_posting_route(client):
|
||||||
|
_seed_via_api(client)
|
||||||
|
client.post(
|
||||||
|
"/month/2026-04/entries/2/postings",
|
||||||
|
data={"occurred_on": "2026-04-01", "amount": "1100"},
|
||||||
|
)
|
||||||
|
# posting id is 1 (first created)
|
||||||
|
response = client.post(
|
||||||
|
"/month/2026-04/postings/1",
|
||||||
|
data={"amount": "1200", "description": "fixed amount"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "fixed amount" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_posting_route(client):
|
||||||
|
_seed_via_api(client)
|
||||||
|
client.post(
|
||||||
|
"/month/2026-04/entries/2/postings",
|
||||||
|
data={"occurred_on": "2026-04-01", "amount": "1200"},
|
||||||
|
)
|
||||||
|
response = client.delete("/month/2026-04/postings/1")
|
||||||
|
assert response.status_code == 200
|
||||||
|
# The section partial should render with no postings and applied back to $0
|
||||||
|
assert "$0.00" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_posting_routes_reject_invalid_date(client):
|
||||||
|
_seed_via_api(client)
|
||||||
|
response = client.post(
|
||||||
|
"/month/2026-04/entries/2/postings",
|
||||||
|
data={"occurred_on": "not-a-date", "amount": "100"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_posting_rejected_on_closed_month(client):
|
||||||
|
_seed_via_api(client)
|
||||||
|
# Post balancing transactions so the month can close
|
||||||
|
client.post(
|
||||||
|
"/month/2026-04/entries/1/postings",
|
||||||
|
data={"occurred_on": "2026-04-01", "amount": "1200"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/month/2026-04/entries/2/postings",
|
||||||
|
data={"occurred_on": "2026-04-01", "amount": "1200"},
|
||||||
|
)
|
||||||
|
client.post("/month/2026-04/activate")
|
||||||
|
close = client.post("/month/2026-04/close")
|
||||||
|
assert close.status_code == 204
|
||||||
|
# Now try to add a new posting on the closed month
|
||||||
|
response = client.post(
|
||||||
|
"/month/2026-04/entries/1/postings",
|
||||||
|
data={"occurred_on": "2026-04-20", "amount": "1"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_applied_update_via_posting_flips_zero_tone(client):
|
||||||
|
_seed_via_api(client)
|
||||||
|
response = client.post(
|
||||||
|
"/month/2026-04/entries/2/postings",
|
||||||
|
data={"occurred_on": "2026-04-01", "amount": "1200"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
# With only rent applied, applied zero = 0 - 1200 = -1200 -> negative
|
||||||
|
assert "tone-negative" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_posting_count_badge_renders(client):
|
||||||
|
_seed_via_api(client)
|
||||||
|
for i in range(3):
|
||||||
|
client.post(
|
||||||
|
"/month/2026-04/entries/2/postings",
|
||||||
|
data={"occurred_on": "2026-04-01", "amount": "400"},
|
||||||
|
)
|
||||||
|
page = client.get("/month/2026-04")
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert "3 txns" in page.text
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from quartermaster import month_service, service
|
from quartermaster import month_service, service
|
||||||
from quartermaster.models import Section
|
from quartermaster.models import Section
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(db, month, entry_id, amount):
|
||||||
|
return month_service.add_posting(
|
||||||
|
db, month, entry_id, date.today(), Decimal(str(amount)), description="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_zero_tone_classification():
|
def test_zero_tone_classification():
|
||||||
assert service.zero_tone(Decimal("0")) == "zero"
|
assert service.zero_tone(Decimal("0")) == "zero"
|
||||||
assert service.zero_tone(Decimal("0.00")) == "zero"
|
assert service.zero_tone(Decimal("0.00")) == "zero"
|
||||||
|
|
@ -66,12 +73,8 @@ def test_month_zero_reflects_applied_updates(db):
|
||||||
month = month_service.create_month(db, "2026-04")
|
month = month_service.create_month(db, "2026-04")
|
||||||
income_entry = next(e for e in month.entries if e.origin_name == "Paycheck")
|
income_entry = next(e for e in month.entries if e.origin_name == "Paycheck")
|
||||||
rent_entry = next(e for e in month.entries if e.origin_name == "Rent")
|
rent_entry = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
month_service.update_month_entry(
|
_apply(db, month, income_entry.id, "2500.00")
|
||||||
db, month, income_entry.id, applied=Decimal("2500.00")
|
_apply(db, month, rent_entry.id, "1200.00")
|
||||||
)
|
|
||||||
month_service.update_month_entry(
|
|
||||||
db, month, rent_entry.id, applied=Decimal("1200.00")
|
|
||||||
)
|
|
||||||
db.refresh(month)
|
db.refresh(month)
|
||||||
z = month_service.month_zero(month)
|
z = month_service.month_zero(month)
|
||||||
# applied income 2500 - applied (1200 + 0) = 1300
|
# applied income 2500 - applied (1200 + 0) = 1300
|
||||||
|
|
@ -117,9 +120,10 @@ def test_month_entry_update_returns_zero_widget_oob(client):
|
||||||
data={"name": "Rent", "amount": "1200.00"},
|
data={"name": "Rent", "amount": "1200.00"},
|
||||||
)
|
)
|
||||||
client.post("/month/2026-04/create")
|
client.post("/month/2026-04/create")
|
||||||
# updating rent applied should bring the month zero widget back with new values
|
# posting rent applied via the ledger should bring the month zero widget back with new values
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/month/2026-04/entries/2", data={"applied": "1200.00"}
|
"/month/2026-04/entries/2/postings",
|
||||||
|
data={"occurred_on": "2026-04-01", "amount": "1200.00"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert 'id="zero-widget"' in response.text
|
assert 'id="zero-widget"' in response.text
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue