Backing transaction ledger: Postings replace the applied field #20
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
|
||||
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import select
|
||||
|
|
@ -24,6 +24,7 @@ from quartermaster.models import (
|
|||
MonthDebtTarget,
|
||||
MonthEntry,
|
||||
MonthState,
|
||||
Posting,
|
||||
Section,
|
||||
)
|
||||
|
||||
|
|
@ -125,7 +126,6 @@ def create_month(db: Session, year_month: str) -> Month:
|
|||
section=e.section,
|
||||
name=e.name,
|
||||
planned=e.amount,
|
||||
applied=Decimal("0.00"),
|
||||
notes=e.notes,
|
||||
origin_name=e.name,
|
||||
origin_planned=e.amount,
|
||||
|
|
@ -238,7 +238,6 @@ def add_month_entry(
|
|||
section=section,
|
||||
name=name.strip(),
|
||||
planned=planned,
|
||||
applied=Decimal("0.00"),
|
||||
notes=_clean_notes(notes),
|
||||
origin_name=None,
|
||||
origin_planned=None,
|
||||
|
|
@ -277,7 +276,6 @@ def update_month_entry(
|
|||
*,
|
||||
name: str | None = None,
|
||||
planned: Decimal | None = None,
|
||||
applied: Decimal | None = None,
|
||||
notes: str | None | object = _NOTES_SENTINEL,
|
||||
) -> MonthEntry | None:
|
||||
entry = get_month_entry(db, month, entry_id)
|
||||
|
|
@ -287,8 +285,6 @@ def update_month_entry(
|
|||
entry.name = name.strip()
|
||||
if planned is not None:
|
||||
entry.planned = planned
|
||||
if applied is not None:
|
||||
entry.applied = applied
|
||||
if notes is not _NOTES_SENTINEL:
|
||||
entry.notes = _clean_notes(notes) # type: ignore[arg-type]
|
||||
db.commit()
|
||||
|
|
@ -296,6 +292,85 @@ def update_month_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:
|
||||
if month.target is not None:
|
||||
return month.target
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
|
|
@ -242,7 +243,6 @@ def update_month_entry(
|
|||
request: Request,
|
||||
name: str | None = Form(None),
|
||||
planned: str | None = Form(None),
|
||||
applied: str | None = Form(None),
|
||||
notes: str | None = Form(None),
|
||||
db: Session = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
|
|
@ -253,11 +253,9 @@ def update_month_entry(
|
|||
if not clean_name:
|
||||
raise HTTPException(status_code=400, detail="name must not be empty")
|
||||
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(
|
||||
name=clean_name,
|
||||
planned=parsed_planned,
|
||||
applied=parsed_applied,
|
||||
)
|
||||
if notes is not None:
|
||||
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)
|
||||
def update_month_target(
|
||||
year_month: str,
|
||||
|
|
|
|||
|
|
@ -421,6 +421,40 @@ tr.add-row td {
|
|||
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 {
|
||||
font-family: var(--sans);
|
||||
font-weight: 500;
|
||||
|
|
@ -523,11 +557,10 @@ tr.entry-notes-row input.notes-input::placeholder {
|
|||
color: var(--rule);
|
||||
font-style: italic;
|
||||
}
|
||||
tr.entry-notes-row:has(input:placeholder-shown) { display: none; }
|
||||
tr.entry:hover + tr.entry-notes-row:has(input:placeholder-shown) {
|
||||
display: block;
|
||||
opacity: 0.5;
|
||||
}
|
||||
/* Empty notes render subtly (placeholder only) so they stay clickable. */
|
||||
tr.entry-notes-row:has(input:placeholder-shown) { opacity: 0.55; }
|
||||
tr.entry-notes-row:hover,
|
||||
tr.entry-notes-row:has(input:focus) { opacity: 1; }
|
||||
|
||||
.tag {
|
||||
font-family: var(--sans);
|
||||
|
|
@ -675,6 +708,300 @@ tr.entry:hover + tr.entry-notes-row:has(input:placeholder-shown) {
|
|||
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) */
|
||||
input[disabled],
|
||||
select[disabled],
|
||||
|
|
|
|||
|
|
@ -2,68 +2,61 @@
|
|||
<div class="section-header">
|
||||
<h2>{{ section.label }}</h2>
|
||||
<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="planned">${{ '%.2f' | format(section.total_planned) }}</span>
|
||||
<span class="planned">${{ '{:,.2f}'.format(section.total_planned) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<table class="entries month-entries">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-name">Name</th>
|
||||
<th class="col-planned">Planned</th>
|
||||
<th class="col-applied">Applied</th>
|
||||
<th class="col-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div class="entries month-entries">
|
||||
{% for row in section.rows %}
|
||||
<tr class="entry state-{{ row.state.value }}" data-entry-id="{{ row.entry.id }}">
|
||||
<td class="entry-name">
|
||||
{% set applied = row.entry.applied %}
|
||||
<details class="entry-block" data-entry-id="{{ row.entry.id }}">
|
||||
<summary class="entry-row state-{{ row.state.value }}" style="--ratio: {{ (applied / row.entry.planned)|round(4) if row.entry.planned > 0 else 0 }}">
|
||||
<span class="caret" aria-hidden="true"></span>
|
||||
<span class="entry-name">
|
||||
{% if editable %}
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value="{{ row.entry.name }}"
|
||||
{% if editable %}
|
||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||
hx-trigger="change"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
{% else %}disabled{% endif %}
|
||||
aria-label="Name"
|
||||
>
|
||||
{% else %}
|
||||
<span class="readonly">{{ row.entry.name }}</span>
|
||||
{% endif %}
|
||||
{% if row.state.value == 'edited' %}
|
||||
<span class="tag tag-edited">modified</span>
|
||||
{% elif row.state.value == 'new_in_month' %}
|
||||
<span class="tag tag-new">new this month</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="entry-amount">
|
||||
</span>
|
||||
<span class="entry-amount planned">
|
||||
{% if editable %}
|
||||
<input
|
||||
type="number" step="0.01" min="0"
|
||||
name="planned"
|
||||
value="{{ '%.2f' | format(row.entry.planned) }}"
|
||||
{% if editable %}
|
||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||
hx-trigger="change"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
{% else %}disabled{% endif %}
|
||||
aria-label="Planned"
|
||||
>
|
||||
</td>
|
||||
<td class="entry-amount">
|
||||
<input
|
||||
type="number" step="0.01" min="0"
|
||||
name="applied"
|
||||
value="{{ '%.2f' | format(row.entry.applied) }}"
|
||||
{% if editable %}
|
||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||
hx-trigger="change"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
{% else %}disabled{% endif %}
|
||||
>
|
||||
</td>
|
||||
<td class="entry-actions">
|
||||
{% else %}
|
||||
<span class="readonly">${{ '{:,.2f}'.format(row.entry.planned) }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="entry-amount applied-cell" aria-label="Applied">
|
||||
<span class="value">${{ '{:,.2f}'.format(applied) }}</span>
|
||||
{% if row.entry.postings|length > 0 %}
|
||||
<span class="count">· {{ row.entry.postings|length }} txn{% if row.entry.postings|length != 1 %}s{% endif %}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="entry-actions">
|
||||
{% if editable %}
|
||||
<button
|
||||
class="delete"
|
||||
|
|
@ -74,32 +67,144 @@
|
|||
aria-label="Delete {{ row.entry.name }}"
|
||||
>×</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="entry-notes-row">
|
||||
<td colspan="4">
|
||||
</span>
|
||||
</summary>
|
||||
<div class="entry-body">
|
||||
{% if editable %}
|
||||
<div class="entry-notes">
|
||||
<input
|
||||
class="notes-input"
|
||||
type="text"
|
||||
name="notes"
|
||||
value="{{ row.entry.notes or '' }}"
|
||||
placeholder="notes..."
|
||||
{% if editable %}
|
||||
placeholder="notes for this entry..."
|
||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||
hx-trigger="change"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
aria-label="Notes for {{ row.entry.name }}"
|
||||
{% else %}disabled{% endif %}
|
||||
aria-label="Notes"
|
||||
>
|
||||
</div>
|
||||
{% elif row.entry.notes %}
|
||||
<div class="entry-notes readonly">{{ row.entry.notes }}</div>
|
||||
{% endif %}
|
||||
<table class="transactions">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-date">Date</th>
|
||||
<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="4">No entries.</td></tr>
|
||||
<tr class="empty"><td colspan="5">No transactions yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if editable %}
|
||||
<form
|
||||
class="add-posting-form"
|
||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}/postings"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-on::after-request="if(event.detail.successful) this.reset()"
|
||||
>
|
||||
<input type="date" name="occurred_on" required value="{{ month.year_month }}-01" aria-label="Date">
|
||||
<input type="text" name="description" placeholder="description" aria-label="Description">
|
||||
<input type="text" name="payee" placeholder="payee" aria-label="Payee">
|
||||
<input type="number" step="0.01" name="amount" placeholder="0.00" required aria-label="Amount">
|
||||
<button type="submit">Add transaction</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="empty-row">No entries.</div>
|
||||
{% endfor %}
|
||||
{% if editable %}
|
||||
<tr class="add-row">
|
||||
<td colspan="4">
|
||||
<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"
|
||||
|
|
@ -112,9 +217,8 @@
|
|||
<button type="submit">Add</button>
|
||||
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@
|
|||
{% endfor %}
|
||||
<tr class="add-row">
|
||||
<td colspan="4">
|
||||
<details class="add-entry">
|
||||
<summary><span class="add-trigger">+ add {{ section.label|lower }}</span></summary>
|
||||
<form
|
||||
class="add-form"
|
||||
hx-post="/sections/{{ section.section.value }}/entries"
|
||||
|
|
@ -56,6 +58,7 @@
|
|||
<button type="submit">Add</button>
|
||||
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
||||
</form>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
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.sinking_fund, "Emergency", Decimal("300.00"))
|
||||
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")
|
||||
month_service.update_month_entry(
|
||||
db, month, rent.id, applied=Decimal("1200.00")
|
||||
month_service.add_posting(
|
||||
db, month, rent.id, date.today(), Decimal("1200.00"), description="test"
|
||||
)
|
||||
db.refresh(month)
|
||||
views = {v.group: v for v in month_service.month_group_views(month)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
|
@ -16,10 +17,10 @@ def _seed(db, balance_to_zero=False):
|
|||
service.add_entry(db, Section.other, "Misc", Decimal("60.00"))
|
||||
month = month_service.create_month(db, "2026-04")
|
||||
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:
|
||||
month_service.update_month_entry(
|
||||
db, month, entry.id, applied=entry.planned
|
||||
month_service.add_posting(
|
||||
db, month, entry.id, date.today(), entry.planned, description="seed"
|
||||
)
|
||||
db.refresh(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
|
||||
# (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")
|
||||
month_service.update_month_entry(
|
||||
db, month, food.id, applied=Decimal("50.00")
|
||||
month_service.add_posting(
|
||||
db, month, food.id, date.today(), Decimal("50.00"), description="seed"
|
||||
)
|
||||
db.refresh(month)
|
||||
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:
|
||||
# entry ids 1,2,3 in that order
|
||||
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(
|
||||
"/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(
|
||||
"/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):
|
||||
_seed_via_api(client)
|
||||
client.post("/month/2026-04/activate")
|
||||
# apply some expense without income to produce an unbalanced state
|
||||
client.post("/month/2026-04/entries/2", data={"applied": "100.00"})
|
||||
# post an expense without income to produce an unbalanced state
|
||||
client.post(
|
||||
"/month/2026-04/entries/2/postings",
|
||||
data={"occurred_on": "2026-04-01", "amount": "100.00"},
|
||||
)
|
||||
response = client.post("/month/2026-04/close")
|
||||
assert response.status_code == 400
|
||||
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
|
||||
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
|
||||
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):
|
||||
_seed_via_api(client)
|
||||
client.post("/month/2026-04/activate")
|
||||
# force imbalance
|
||||
client.post("/month/2026-04/entries/2", data={"applied": "100.00"})
|
||||
# force imbalance via a posting
|
||||
client.post(
|
||||
"/month/2026-04/entries/2/postings",
|
||||
data={"occurred_on": "2026-04-01", "amount": "100.00"},
|
||||
)
|
||||
response = client.get("/month/2026-04")
|
||||
assert response.status_code == 200
|
||||
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/close")
|
||||
response = client.get("/month/2026-04")
|
||||
# name inputs rendered with disabled
|
||||
assert "disabled" in response.text
|
||||
# Editable inputs are replaced by readonly spans on closed months,
|
||||
# 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
|
||||
assert "Delete Paycheck" not in response.text
|
||||
|
|
|
|||
|
|
@ -38,23 +38,24 @@ def test_created_month_renders_snapshot(client):
|
|||
assert "Paycheck" in response.text
|
||||
assert "Rent" in response.text
|
||||
assert "Card A" in response.text
|
||||
# totals: applied / planned
|
||||
assert "$2500.00" in response.text
|
||||
assert "$1200.00" in response.text
|
||||
# totals: applied / planned rendered with thousands separators
|
||||
assert "$2,500.00" in response.text
|
||||
assert "$1,200.00" in response.text
|
||||
|
||||
|
||||
def test_applied_update_returns_section_partial(client):
|
||||
_seed_budget_via_api(client)
|
||||
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(
|
||||
"/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
|
||||
# rent is in fixed_bill section; total_applied should now include 1200
|
||||
assert 'id="section-fixed_bill"' in response.text
|
||||
# two $1200.00 occurrences: total_applied and planned (same amount)
|
||||
assert response.text.count("$1200.00") >= 2
|
||||
assert "$1,200.00" in response.text or "$1200.00" in response.text
|
||||
|
||||
|
||||
def test_name_edit_flips_to_modified(client):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
|
@ -8,6 +9,13 @@ from quartermaster import month_service, service
|
|||
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):
|
||||
income = service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00"))
|
||||
rent = service.add_entry(
|
||||
|
|
@ -96,9 +104,8 @@ def test_applied_update_changes_totals(db):
|
|||
_seed_budget(db)
|
||||
month = month_service.create_month(db, "2026-04")
|
||||
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||
month_service.update_month_entry(
|
||||
db, month, rent.id, applied=Decimal("1200.00")
|
||||
)
|
||||
_apply(db, month, rent.id, "1200.00")
|
||||
db.refresh(month)
|
||||
view = month_service.section_view(month, Section.fixed_bill, "Fixed")
|
||||
assert view.total_applied == 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")
|
||||
response = client.get("/month/2026-04")
|
||||
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
|
||||
|
|
|
|||
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 datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from quartermaster import month_service, service
|
||||
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():
|
||||
assert service.zero_tone(Decimal("0")) == "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")
|
||||
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")
|
||||
month_service.update_month_entry(
|
||||
db, month, income_entry.id, applied=Decimal("2500.00")
|
||||
)
|
||||
month_service.update_month_entry(
|
||||
db, month, rent_entry.id, applied=Decimal("1200.00")
|
||||
)
|
||||
_apply(db, month, income_entry.id, "2500.00")
|
||||
_apply(db, month, rent_entry.id, "1200.00")
|
||||
db.refresh(month)
|
||||
z = month_service.month_zero(month)
|
||||
# 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"},
|
||||
)
|
||||
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(
|
||||
"/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 'id="zero-widget"' in response.text
|
||||
|
|
|
|||
Loading…
Reference in a new issue