Compare commits

...

4 commits

Author SHA1 Message Date
archeious
e80a3508b6 test: cover posting CRUD and update existing tests to use the ledger
New test_postings.py walks service and route layers: add sums into
applied, negatives are allowed, update and delete round-trip, entry
deletion cascades postings, order is desc by date, update_month_entry
rejects the removed applied kwarg. Route tests assert HTTP behaviour,
invalid-date rejection, closed-month lock, tone flip after a posting,
and the "N txns" count badge renders.

Existing tests that previously set applied via update_month_entry or
the entries route now use add_posting or POST to /postings. Format
assertions updated to match the new thousands-separator number
rendering and the replaced entry-notes-row markup.

Refs #19

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:34:53 -06:00
archeious
cca05fe9fc feat(ledger): expandable entry rows with transactions table and add form
Each month entry becomes a <details> block. The summary is the same
dense row (name, planned, applied, delete) plus a leading caret and
an applied cell that shows the transaction count ("$412.33 · 7 txns")
when postings exist. Expansion adds no horizontal space.

Expanded body holds: the entry's notes input, a transactions table
with date / description / payee / amount / delete per posting, and
an inline add-transaction form (date, description, payee, amount,
submit). Every field is HTMX-wired so editing any cell triggers the
section partial re-render with fresh derived totals.

Closed month: name / planned / notes / posting fields all collapse
to read-only spans, delete buttons and add forms are omitted. The
existing editable flag controls the branching.

Refs #19

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:34:48 -06:00
archeious
52bc52ec7f feat(ledger): service CRUD for postings and three new routes
add_posting / update_posting / delete_posting all go through
ensure_editable(month), so closed months reject posting mutations
with the same lifecycle guard as every other mutation. Negative
amounts are allowed for refunds / corrections. Dates are parsed as
ISO (YYYY-MM-DD) but not constrained to the month for now.

update_month_entry loses the applied keyword; the route no longer
accepts an applied form field. applied is derived only from now
on. Three new routes wire the ledger:

  POST   /month/{ym}/entries/{entry_id}/postings
  POST   /month/{ym}/postings/{posting_id}
  DELETE /month/{ym}/postings/{posting_id}

Each returns the updated section partial plus OOB swaps for the
zero widget and all four group totals, same pattern the existing
mutations use.

Refs #19

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:34:41 -06:00
archeious
517578f4f3 feat(db): add Posting model, derive MonthEntry.applied, seed opening balances
Posting is a child of MonthEntry with occurred_on, amount, optional
description and payee. Cascade delete so removing an entry wipes its
ledger. Ordered on load by occurred_on DESC for readable UIs.

MonthEntry.applied becomes a @property summing posting amounts. The
stored applied column is dropped in the same migration.

The migration walks existing month_entry rows: for every non-zero
applied value, it inserts one opening-balance posting on the month's
activated_at (or created_at) date with description "opening balance"
and amount equal to the existing applied. Empty applied values get
no opening posting. Closed months go through the same path; their
totals stay intact via that single seeded row.

Downgrade is symmetric: re-adds the column and populates from
SUM(postings).

Refs #19

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:34:34 -06:00
13 changed files with 1108 additions and 130 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
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")

View file

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

View file

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

View file

@ -675,6 +675,295 @@ 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 5.5rem 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.3rem;
}
.entry-block .applied-cell .value {
font-weight: 500;
}
.entry-block .applied-cell .count {
font-family: var(--sans);
font-size: 0.66rem;
letter-spacing: 0.16em;
text-transform: lowercase;
color: var(--muted);
}
.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.6rem 4.6rem 1rem;
gap: 0.4rem;
}
.entry-block .entry-body { padding: 0.5rem 0.75rem 0.6rem; }
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],

View file

@ -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,142 @@
aria-label="Delete {{ row.entry.name }}"
>&times;</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"
>&times;</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">
<form
class="add-form month-add-form"
hx-post="/month/{{ month.year_month }}/sections/{{ section.section.value }}/entries"
@ -112,9 +215,7 @@
<button type="submit">Add</button>
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
</form>
</td>
</tr>
</div>
{% endif %}
</tbody>
</table>
</div>
</section>

View file

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

View file

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

View file

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

View file

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

View file

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