Backing transaction ledger: Postings replace the applied field #20

Merged
claude-code merged 7 commits from feat/19-posting-ledger into main 2026-04-17 17:54:16 -06:00
14 changed files with 1169 additions and 147 deletions

View file

@ -0,0 +1,123 @@
"""add posting ledger, drop month_entry.applied
Revision ID: cc60e7f73a1c
Revises: a4ec4f8f6e9f
Create Date: 2026-04-17 17:25:53.487094
Creates the posting table, seeds one "opening balance" posting per
existing month_entry whose applied value is non-zero, and drops the
applied column from month_entry. After this revision applied is
computed as SUM(posting.amount) over the entry's postings.
"""
from datetime import datetime, timezone
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "cc60e7f73a1c"
down_revision: Union[str, Sequence[str], None] = "a4ec4f8f6e9f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Create posting table
op.create_table(
"posting",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("month_entry_id", sa.Integer(), nullable=False),
sa.Column("occurred_on", sa.Date(), nullable=False),
sa.Column("amount", sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column("description", sa.String(length=256), nullable=True),
sa.Column("payee", sa.String(length=128), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.ForeignKeyConstraint(
["month_entry_id"], ["month_entry.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("posting", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_posting_month_entry_id"),
["month_entry_id"],
unique=False,
)
# 2. Seed opening-balance postings from the existing month_entry.applied
conn = op.get_bind()
rows = conn.execute(
sa.text(
"SELECT me.id, me.applied, m.activated_at, m.created_at "
"FROM month_entry me JOIN month m ON m.id = me.month_id "
"WHERE me.applied IS NOT NULL AND me.applied != 0"
)
).fetchall()
now = datetime.now(timezone.utc).replace(tzinfo=None)
for row in rows:
me_id, applied, activated_at, created_at = row
occurred = activated_at or created_at
# Truncate the timestamp to a date for the DATE column
if hasattr(occurred, "date"):
occurred = occurred.date()
if isinstance(occurred, str):
# SQLite may give back an ISO string; take the date portion
occurred = occurred[:10]
conn.execute(
sa.text(
"INSERT INTO posting "
"(month_entry_id, occurred_on, amount, description, created_at, updated_at) "
"VALUES (:me_id, :occurred, :amount, :desc, :now, :now)"
),
{
"me_id": me_id,
"occurred": occurred,
"amount": applied,
"desc": "opening balance",
"now": now,
},
)
# 3. Drop the applied column from month_entry
with op.batch_alter_table("month_entry", schema=None) as batch_op:
batch_op.drop_column("applied")
def downgrade() -> None:
# Add the applied column back with a default of 0.00 for NOT NULL
with op.batch_alter_table("month_entry", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"applied",
sa.NUMERIC(precision=10, scale=2),
server_default=sa.text("'0.00'"),
nullable=False,
)
)
# Restore applied as SUM of postings
conn = op.get_bind()
conn.execute(
sa.text(
"UPDATE month_entry SET applied = ("
"SELECT COALESCE(SUM(amount), 0) FROM posting "
"WHERE posting.month_entry_id = month_entry.id"
")"
)
)
with op.batch_alter_table("posting", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_posting_month_entry_id"))
op.drop_table("posting")

View file

@ -1,11 +1,12 @@
from __future__ import annotations 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")

View file

@ -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

View file

@ -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,

View file

@ -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],

View file

@ -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 }}"
>&times;</button> >&times;</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"
>&times;</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>

View file

@ -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>

View file

@ -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)}

View file

@ -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

View file

@ -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):

View file

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

View file

@ -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
View 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

View file

@ -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