Compare commits
5 commits
b2d16120d2
...
2d7ce333ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d7ce333ea | ||
|
|
9da7205f77 | ||
|
|
647bf257f4 | ||
|
|
c6d4d65fe6 | ||
|
|
b63daa958b |
16 changed files with 1262 additions and 25 deletions
54
README.md
54
README.md
|
|
@ -2,19 +2,22 @@
|
|||
|
||||
Household budget tracker. FastAPI + HTMX frontend, SQLite backend.
|
||||
|
||||
## Sections
|
||||
## Pages
|
||||
|
||||
The budget page shows one card per section. Every section accepts a name and
|
||||
amount per entry and displays a running total, except *Primary Debt Target*,
|
||||
which is a pointer to one of the *Debt Minimums* rows.
|
||||
* `/` budget configuration. One section per category (Incomes, Fixed Amount
|
||||
Bills, Debt Minimums, Primary Debt Target, Food and Essentials,
|
||||
Subscriptions, Other). Every section accepts name + amount entries and
|
||||
shows a running total. The Primary Debt Target is a pointer to a Debt
|
||||
Minimums row.
|
||||
* `/month/YYYY-MM` monthly view. Snapshots the budget at creation time and
|
||||
tracks an `applied` amount per entry alongside the planned amount. Each
|
||||
row is annotated when its name or planned value has been edited away
|
||||
from the snapshot, or when the row was added after creation. Per-month
|
||||
debt target is independent of the budget's target after snapshot.
|
||||
|
||||
* Incomes
|
||||
* Fixed Amount Bills
|
||||
* Debt Minimums
|
||||
* Primary Debt Target (pointer)
|
||||
* Food and Essentials
|
||||
* Subscriptions
|
||||
* Other
|
||||
Navigate between months with the prev / next buttons or the dropdown
|
||||
picker. A "This month" link on `/` jumps to the current `YYYY-MM`; if it
|
||||
has not been created yet, you land on the create flow.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
|
@ -51,19 +54,22 @@ Tests run against an in-memory SQLite database; no migration step needed.
|
|||
|
||||
```
|
||||
src/quartermaster/
|
||||
main.py FastAPI app factory
|
||||
routes.py HTTP handlers, HTMX partials
|
||||
service.py Queries, totals, target logic
|
||||
models.py SQLAlchemy models and Section enum
|
||||
db.py Engine, session, PRAGMA foreign_keys=ON
|
||||
config.py DB URL resolution
|
||||
templates/ Jinja2 templates (base, index, partials)
|
||||
static/ CSS
|
||||
alembic/ Migrations
|
||||
tests/ pytest suite
|
||||
main.py FastAPI app factory
|
||||
routes.py Budget configuration HTTP handlers
|
||||
routes_month.py Monthly view HTTP handlers
|
||||
service.py Budget queries, totals, target logic
|
||||
month_service.py Snapshot, deviation, per-month CRUD
|
||||
models.py SQLAlchemy models and Section enum
|
||||
db.py Engine, session, PRAGMA foreign_keys=ON
|
||||
config.py DB URL resolution
|
||||
templates/ Jinja2 templates (base, index, month, partials)
|
||||
static/ CSS
|
||||
alembic/ Migrations
|
||||
tests/ pytest suite
|
||||
```
|
||||
|
||||
## Scope
|
||||
## Deferred
|
||||
|
||||
Single-month budget. Multi-month support is planned for a later milestone
|
||||
and is intentionally not modelled yet.
|
||||
A transaction log that rolls up into `applied` on a per-entry per-month
|
||||
basis is deferred. Once implemented it may replace the hand-edited applied
|
||||
field.
|
||||
|
|
|
|||
73
alembic/versions/03ebe3c07262_add_month_snapshot_tables.py
Normal file
73
alembic/versions/03ebe3c07262_add_month_snapshot_tables.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"""add month snapshot tables
|
||||
|
||||
Revision ID: 03ebe3c07262
|
||||
Revises: f1ccdc4bc1bf
|
||||
Create Date: 2026-04-17 11:33:45.853510
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '03ebe3c07262'
|
||||
down_revision: Union[str, Sequence[str], None] = 'f1ccdc4bc1bf'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('month',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('year_month', sa.String(length=7), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('year_month')
|
||||
)
|
||||
op.create_table('month_entry',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('month_id', sa.Integer(), nullable=False),
|
||||
sa.Column('section', sa.Enum('income', 'fixed_bill', 'debt_minimum', 'food', 'subscription', 'other', name='section', native_enum=False, length=32), nullable=False),
|
||||
sa.Column('name', sa.String(length=128), nullable=False),
|
||||
sa.Column('planned', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('applied', sa.Numeric(precision=10, scale=2), server_default='0.00', nullable=False),
|
||||
sa.Column('origin_name', sa.String(length=128), nullable=True),
|
||||
sa.Column('origin_planned', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('source_entry_id', sa.Integer(), 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_id'], ['month.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['source_entry_id'], ['entry.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('month_id', 'id')
|
||||
)
|
||||
with op.batch_alter_table('month_entry', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_month_entry_month_id'), ['month_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_month_entry_section'), ['section'], unique=False)
|
||||
|
||||
op.create_table('month_debt_target',
|
||||
sa.Column('month_id', sa.Integer(), autoincrement=False, nullable=False),
|
||||
sa.Column('month_entry_id', sa.Integer(), nullable=True),
|
||||
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='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['month_id'], ['month.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('month_id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('month_debt_target')
|
||||
with op.batch_alter_table('month_entry', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_month_entry_section'))
|
||||
batch_op.drop_index(batch_op.f('ix_month_entry_month_id'))
|
||||
|
||||
op.drop_table('month_entry')
|
||||
op.drop_table('month')
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -6,6 +6,7 @@ from fastapi import FastAPI
|
|||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from quartermaster.routes import router
|
||||
from quartermaster.routes_month import router as month_router
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ def create_app() -> FastAPI:
|
|||
app = FastAPI(title="Quartermaster", version="0.1.0")
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
app.include_router(router)
|
||||
app.include_router(month_router)
|
||||
return app
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from sqlalchemy import (
|
|||
ForeignKey,
|
||||
Numeric,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
|
@ -75,3 +76,82 @@ class DebtTarget(Base):
|
|||
)
|
||||
|
||||
entry: Mapped[Entry | None] = relationship(Entry, lazy="joined")
|
||||
|
||||
|
||||
class Month(Base):
|
||||
__tablename__ = "month"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
year_month: Mapped[str] = mapped_column(String(7), nullable=False, unique=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
entries: Mapped[list["MonthEntry"]] = relationship(
|
||||
back_populates="month", cascade="all, delete-orphan"
|
||||
)
|
||||
target: Mapped["MonthDebtTarget | None"] = relationship(
|
||||
back_populates="month",
|
||||
cascade="all, delete-orphan",
|
||||
uselist=False,
|
||||
lazy="joined",
|
||||
)
|
||||
|
||||
|
||||
class MonthEntry(Base):
|
||||
__tablename__ = "month_entry"
|
||||
__table_args__ = (UniqueConstraint("month_id", "id"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
month_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("month.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
section: Mapped[Section] = mapped_column(
|
||||
Enum(Section, native_enum=False, length=32), nullable=False, index=True
|
||||
)
|
||||
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",
|
||||
)
|
||||
origin_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
origin_planned: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(10, 2), nullable=True
|
||||
)
|
||||
source_entry_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("entry.id", ondelete="SET NULL"), 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,
|
||||
)
|
||||
|
||||
month: Mapped[Month] = relationship(back_populates="entries")
|
||||
|
||||
|
||||
class MonthDebtTarget(Base):
|
||||
__tablename__ = "month_debt_target"
|
||||
|
||||
month_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("month.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
autoincrement=False,
|
||||
)
|
||||
month_entry_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("month_entry.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
month: Mapped[Month] = relationship(back_populates="target")
|
||||
entry: Mapped[MonthEntry | None] = relationship(MonthEntry, lazy="joined")
|
||||
|
|
|
|||
228
src/quartermaster/month_service.py
Normal file
228
src/quartermaster/month_service.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from quartermaster.models import (
|
||||
DebtTarget,
|
||||
Entry,
|
||||
Month,
|
||||
MonthDebtTarget,
|
||||
MonthEntry,
|
||||
Section,
|
||||
)
|
||||
|
||||
YEAR_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
|
||||
|
||||
|
||||
class DeviationState(str, Enum):
|
||||
unchanged = "unchanged"
|
||||
edited = "edited"
|
||||
new_in_month = "new_in_month"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonthRow:
|
||||
entry: MonthEntry
|
||||
state: DeviationState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonthSectionView:
|
||||
section: Section
|
||||
label: str
|
||||
rows: list[MonthRow]
|
||||
total_planned: Decimal
|
||||
total_applied: Decimal
|
||||
|
||||
|
||||
def valid_year_month(year_month: str) -> bool:
|
||||
return bool(YEAR_MONTH_RE.match(year_month))
|
||||
|
||||
|
||||
def current_year_month() -> str:
|
||||
today = date.today()
|
||||
return f"{today.year:04d}-{today.month:02d}"
|
||||
|
||||
|
||||
def shift_year_month(year_month: str, delta: int) -> str:
|
||||
year, month = (int(part) for part in year_month.split("-"))
|
||||
index = (year * 12 + (month - 1)) + delta
|
||||
new_year, new_month0 = divmod(index, 12)
|
||||
return f"{new_year:04d}-{new_month0 + 1:02d}"
|
||||
|
||||
|
||||
def get_month(db: Session, year_month: str) -> Month | None:
|
||||
stmt = select(Month).where(Month.year_month == year_month)
|
||||
return db.scalar(stmt)
|
||||
|
||||
|
||||
def list_months(db: Session) -> list[Month]:
|
||||
stmt = select(Month).order_by(Month.year_month)
|
||||
return list(db.scalars(stmt))
|
||||
|
||||
|
||||
def create_month(db: Session, year_month: str) -> Month:
|
||||
if not valid_year_month(year_month):
|
||||
raise ValueError("year_month must be formatted as YYYY-MM")
|
||||
existing = get_month(db, year_month)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
month = Month(year_month=year_month)
|
||||
db.add(month)
|
||||
db.flush()
|
||||
|
||||
budget_entries = list(db.scalars(select(Entry).order_by(Entry.id)))
|
||||
source_to_month_entry: dict[int, MonthEntry] = {}
|
||||
for e in budget_entries:
|
||||
month_entry = MonthEntry(
|
||||
month_id=month.id,
|
||||
section=e.section,
|
||||
name=e.name,
|
||||
planned=e.amount,
|
||||
applied=Decimal("0.00"),
|
||||
origin_name=e.name,
|
||||
origin_planned=e.amount,
|
||||
source_entry_id=e.id,
|
||||
)
|
||||
db.add(month_entry)
|
||||
source_to_month_entry[e.id] = month_entry
|
||||
|
||||
db.flush()
|
||||
|
||||
budget_target = db.get(DebtTarget, 1)
|
||||
target_entry_id: int | None = None
|
||||
if budget_target is not None and budget_target.debt_minimum_id is not None:
|
||||
mapped = source_to_month_entry.get(budget_target.debt_minimum_id)
|
||||
if mapped is not None:
|
||||
target_entry_id = mapped.id
|
||||
|
||||
db.add(
|
||||
MonthDebtTarget(month_id=month.id, month_entry_id=target_entry_id)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(month)
|
||||
return month
|
||||
|
||||
|
||||
def deviation_state(entry: MonthEntry) -> DeviationState:
|
||||
if entry.origin_name is None or entry.origin_planned is None:
|
||||
return DeviationState.new_in_month
|
||||
if entry.name != entry.origin_name or entry.planned != entry.origin_planned:
|
||||
return DeviationState.edited
|
||||
return DeviationState.unchanged
|
||||
|
||||
|
||||
def _rows(entries: list[MonthEntry]) -> list[MonthRow]:
|
||||
return [MonthRow(entry=e, state=deviation_state(e)) for e in entries]
|
||||
|
||||
|
||||
def section_view(month: Month, section: Section, label: str) -> MonthSectionView:
|
||||
entries = [e for e in month.entries if e.section == section]
|
||||
entries.sort(key=lambda e: e.id)
|
||||
rows = _rows(entries)
|
||||
total_planned = sum((e.planned for e in entries), Decimal("0"))
|
||||
total_applied = sum((e.applied for e in entries), Decimal("0"))
|
||||
return MonthSectionView(
|
||||
section=section,
|
||||
label=label,
|
||||
rows=rows,
|
||||
total_planned=total_planned,
|
||||
total_applied=total_applied,
|
||||
)
|
||||
|
||||
|
||||
def add_month_entry(
|
||||
db: Session,
|
||||
month: Month,
|
||||
section: Section,
|
||||
name: str,
|
||||
planned: Decimal,
|
||||
) -> MonthEntry:
|
||||
entry = MonthEntry(
|
||||
month_id=month.id,
|
||||
section=section,
|
||||
name=name.strip(),
|
||||
planned=planned,
|
||||
applied=Decimal("0.00"),
|
||||
origin_name=None,
|
||||
origin_planned=None,
|
||||
source_entry_id=None,
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def get_month_entry(db: Session, month: Month, entry_id: int) -> MonthEntry | None:
|
||||
entry = db.get(MonthEntry, entry_id)
|
||||
if entry is None or entry.month_id != month.id:
|
||||
return None
|
||||
return entry
|
||||
|
||||
|
||||
def delete_month_entry(db: Session, month: Month, entry_id: int) -> Section | None:
|
||||
entry = get_month_entry(db, month, entry_id)
|
||||
if entry is None:
|
||||
return None
|
||||
section = entry.section
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
return section
|
||||
|
||||
|
||||
def update_month_entry(
|
||||
db: Session,
|
||||
month: Month,
|
||||
entry_id: int,
|
||||
*,
|
||||
name: str | None = None,
|
||||
planned: Decimal | None = None,
|
||||
applied: Decimal | None = None,
|
||||
) -> MonthEntry | None:
|
||||
entry = get_month_entry(db, month, entry_id)
|
||||
if entry is None:
|
||||
return None
|
||||
if name is not None:
|
||||
entry.name = name.strip()
|
||||
if planned is not None:
|
||||
entry.planned = planned
|
||||
if applied is not None:
|
||||
entry.applied = applied
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def get_month_target(db: Session, month: Month) -> MonthDebtTarget:
|
||||
if month.target is not None:
|
||||
return month.target
|
||||
target = MonthDebtTarget(month_id=month.id, month_entry_id=None)
|
||||
db.add(target)
|
||||
db.commit()
|
||||
db.refresh(target)
|
||||
return target
|
||||
|
||||
|
||||
def set_month_target(
|
||||
db: Session, month: Month, month_entry_id: int | None
|
||||
) -> MonthDebtTarget:
|
||||
target = get_month_target(db, month)
|
||||
if month_entry_id is not None:
|
||||
candidate = get_month_entry(db, month, month_entry_id)
|
||||
if candidate is None or candidate.section != Section.debt_minimum:
|
||||
raise ValueError(
|
||||
"month_entry_id must reference a debt minimum entry in this month"
|
||||
)
|
||||
target.month_entry_id = month_entry_id
|
||||
db.commit()
|
||||
db.refresh(target)
|
||||
return target
|
||||
|
|
@ -8,7 +8,7 @@ from fastapi.responses import HTMLResponse
|
|||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from quartermaster import service
|
||||
from quartermaster import month_service, service
|
||||
from quartermaster.db import get_session
|
||||
from quartermaster.models import SECTION_LABELS, Section
|
||||
|
||||
|
|
@ -62,6 +62,7 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse:
|
|||
sections = [_section_view(db, s) for s in Section]
|
||||
target = service.get_debt_target(db)
|
||||
debt_minimums = service.list_entries(db, Section.debt_minimum)
|
||||
current_ym = month_service.current_year_month()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"index.html",
|
||||
|
|
@ -69,6 +70,8 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse:
|
|||
"sections": sections,
|
||||
"target": target,
|
||||
"debt_minimums": debt_minimums,
|
||||
"current_year_month": current_ym,
|
||||
"all_months": month_service.list_months(db),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
224
src/quartermaster/routes_month.py
Normal file
224
src/quartermaster/routes_month.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from quartermaster import month_service, service
|
||||
from quartermaster.db import get_session
|
||||
from quartermaster.models import SECTION_LABELS, Month, Section
|
||||
from quartermaster.routes import templates
|
||||
|
||||
router = APIRouter(prefix="/month/{year_month}", tags=["month"])
|
||||
|
||||
|
||||
def _parse_amount(raw: str, *, allow_zero: bool = True) -> Decimal:
|
||||
try:
|
||||
amount = Decimal(raw.strip())
|
||||
except (InvalidOperation, AttributeError) as exc:
|
||||
raise HTTPException(status_code=400, detail="amount must be numeric") from exc
|
||||
if amount < 0:
|
||||
raise HTTPException(status_code=400, detail="amount must be non-negative")
|
||||
if amount == 0 and not allow_zero:
|
||||
raise HTTPException(status_code=400, detail="amount must be positive")
|
||||
return amount.quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
def _require_year_month(year_month: str) -> str:
|
||||
if not month_service.valid_year_month(year_month):
|
||||
raise HTTPException(
|
||||
status_code=404, detail="year_month must be formatted as YYYY-MM"
|
||||
)
|
||||
return year_month
|
||||
|
||||
|
||||
def _require_month(db: Session, year_month: str) -> Month:
|
||||
_require_year_month(year_month)
|
||||
month = month_service.get_month(db, year_month)
|
||||
if month is None:
|
||||
raise HTTPException(status_code=404, detail="month has not been created yet")
|
||||
return month
|
||||
|
||||
|
||||
def _section_views(month: Month) -> list[month_service.MonthSectionView]:
|
||||
return [
|
||||
month_service.section_view(month, s, SECTION_LABELS[s]) for s in Section
|
||||
]
|
||||
|
||||
|
||||
def _render_section(
|
||||
request: Request, month: Month, section: Section
|
||||
) -> HTMLResponse:
|
||||
view = month_service.section_view(month, section, SECTION_LABELS[section])
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/month_section.html",
|
||||
{"month": month, "section": view},
|
||||
)
|
||||
|
||||
|
||||
def _render_target(request: Request, db: Session, month: Month) -> HTMLResponse:
|
||||
target = month_service.get_month_target(db, month)
|
||||
debt_minimums = [e for e in month.entries if e.section == Section.debt_minimum]
|
||||
debt_minimums.sort(key=lambda e: e.id)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/month_target.html",
|
||||
{"month": month, "target": target, "debt_minimums": debt_minimums},
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def view_month(
|
||||
year_month: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
_require_year_month(year_month)
|
||||
month = month_service.get_month(db, year_month)
|
||||
prev_ym = month_service.shift_year_month(year_month, -1)
|
||||
next_ym = month_service.shift_year_month(year_month, 1)
|
||||
all_months = month_service.list_months(db)
|
||||
if month is None:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"month_create.html",
|
||||
{
|
||||
"year_month": year_month,
|
||||
"prev_year_month": prev_ym,
|
||||
"next_year_month": next_ym,
|
||||
"all_months": all_months,
|
||||
},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"month.html",
|
||||
{
|
||||
"month": month,
|
||||
"year_month": year_month,
|
||||
"prev_year_month": prev_ym,
|
||||
"next_year_month": next_ym,
|
||||
"all_months": all_months,
|
||||
"sections": _section_views(month),
|
||||
"target": month_service.get_month_target(db, month),
|
||||
"debt_minimums": sorted(
|
||||
(e for e in month.entries if e.section == Section.debt_minimum),
|
||||
key=lambda e: e.id,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
def create_month(
|
||||
year_month: str,
|
||||
db: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
_require_year_month(year_month)
|
||||
try:
|
||||
month_service.create_month(db, year_month)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return Response(
|
||||
status_code=204,
|
||||
headers={"HX-Redirect": f"/month/{year_month}"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sections/{section}/entries", response_class=HTMLResponse)
|
||||
def add_month_entry(
|
||||
year_month: str,
|
||||
section: Section,
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
planned: str = Form(...),
|
||||
db: Session = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
month = _require_month(db, year_month)
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
month_service.add_month_entry(
|
||||
db, month, section, clean_name, _parse_amount(planned)
|
||||
)
|
||||
db.refresh(month)
|
||||
return _render_section(request, month, section)
|
||||
|
||||
|
||||
@router.delete("/entries/{entry_id}", response_class=HTMLResponse)
|
||||
def delete_month_entry(
|
||||
year_month: str,
|
||||
entry_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
month = _require_month(db, year_month)
|
||||
section = month_service.delete_month_entry(db, month, entry_id)
|
||||
if section is None:
|
||||
raise HTTPException(status_code=404, detail="entry not found")
|
||||
db.refresh(month)
|
||||
response = _render_section(request, month, section)
|
||||
if section == Section.debt_minimum:
|
||||
target_html = _render_target(request, db, month).body.decode()
|
||||
response = HTMLResponse(response.body.decode() + target_html)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/entries/{entry_id}", response_class=HTMLResponse)
|
||||
def update_month_entry(
|
||||
year_month: str,
|
||||
entry_id: int,
|
||||
request: Request,
|
||||
name: str | None = Form(None),
|
||||
planned: str | None = Form(None),
|
||||
applied: str | None = Form(None),
|
||||
db: Session = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
month = _require_month(db, year_month)
|
||||
clean_name: str | None = None
|
||||
if name is not None:
|
||||
clean_name = name.strip()
|
||||
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
|
||||
updated = month_service.update_month_entry(
|
||||
db,
|
||||
month,
|
||||
entry_id,
|
||||
name=clean_name,
|
||||
planned=parsed_planned,
|
||||
applied=parsed_applied,
|
||||
)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="entry not found")
|
||||
db.refresh(month)
|
||||
return _render_section(request, month, updated.section)
|
||||
|
||||
|
||||
@router.post("/target", response_class=HTMLResponse)
|
||||
def update_month_target(
|
||||
year_month: str,
|
||||
request: Request,
|
||||
month_entry_id: str = Form(""),
|
||||
db: Session = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
month = _require_month(db, year_month)
|
||||
raw = month_entry_id.strip()
|
||||
target_id: int | None
|
||||
if raw == "":
|
||||
target_id = None
|
||||
else:
|
||||
try:
|
||||
target_id = int(raw)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="month_entry_id must be an integer"
|
||||
) from exc
|
||||
try:
|
||||
month_service.set_month_target(db, month, target_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return _render_target(request, db, month)
|
||||
|
|
@ -156,3 +156,124 @@ button[type=submit] {
|
|||
.target-section .section-header {
|
||||
border-bottom-style: dashed;
|
||||
}
|
||||
|
||||
/* Monthly view ----------------------------------------------------------- */
|
||||
|
||||
.month-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.month-nav .month-label {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.month-nav .nav-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.month-nav .nav-link:hover {
|
||||
background: #f0ece0;
|
||||
}
|
||||
|
||||
.month-nav .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.month-nav .month-picker {
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.total .divider {
|
||||
color: var(--muted);
|
||||
margin: 0 0.25rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.total .planned {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
table.month-entries thead th {
|
||||
text-align: left;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
table.month-entries th.col-planned,
|
||||
table.month-entries th.col-applied {
|
||||
text-align: right;
|
||||
width: 8rem;
|
||||
}
|
||||
|
||||
table.month-entries th.col-actions {
|
||||
width: 2.25rem;
|
||||
}
|
||||
|
||||
table.month-entries td.entry-name input[name="name"] {
|
||||
width: 100%;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
table.month-entries td.entry-amount input {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
tr.entry.state-edited {
|
||||
background: #fff7e0;
|
||||
}
|
||||
|
||||
tr.entry.state-new_in_month {
|
||||
background: #e8f2ff;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
margin-left: 0.4rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.tag-edited {
|
||||
background: #f4d07a;
|
||||
color: #5a3f00;
|
||||
}
|
||||
|
||||
.tag-new {
|
||||
background: #9cc5ef;
|
||||
color: #123a66;
|
||||
}
|
||||
|
||||
.month-add-form {
|
||||
grid-template-columns: 1fr 9rem auto;
|
||||
}
|
||||
|
||||
.month-missing-body {
|
||||
padding: 0.75rem 0.5rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.month-create-form {
|
||||
padding: 0.25rem 0.5rem 0.75rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,23 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="budget">
|
||||
<nav class="month-nav budget-nav">
|
||||
<span class="month-label">Budget configuration</span>
|
||||
<span class="spacer"></span>
|
||||
<a class="nav-link" href="/month/{{ current_year_month }}">This month ({{ current_year_month }})</a>
|
||||
{% if all_months %}
|
||||
<select
|
||||
class="month-picker"
|
||||
onchange="if(this.value){window.location=this.value}"
|
||||
aria-label="Jump to month"
|
||||
>
|
||||
<option value="">Jump to...</option>
|
||||
{% for m in all_months %}
|
||||
<option value="/month/{{ m.year_month }}">{{ m.year_month }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% for section in sections %}
|
||||
{% if section.section.value == 'debt_minimum' %}
|
||||
{% include "partials/section.html" %}
|
||||
|
|
|
|||
14
src/quartermaster/templates/month.html
Normal file
14
src/quartermaster/templates/month.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="budget">
|
||||
{% include "partials/month_nav.html" %}
|
||||
{% for section in sections %}
|
||||
{% if section.section.value == 'debt_minimum' %}
|
||||
{% include "partials/month_section.html" %}
|
||||
{% include "partials/month_target.html" %}
|
||||
{% else %}
|
||||
{% include "partials/month_section.html" %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
23
src/quartermaster/templates/month_create.html
Normal file
23
src/quartermaster/templates/month_create.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="budget">
|
||||
{% include "partials/month_nav.html" %}
|
||||
<section class="section month-missing">
|
||||
<div class="section-header">
|
||||
<h2>No snapshot yet</h2>
|
||||
</div>
|
||||
<p class="month-missing-body">
|
||||
This month has not been created. Creating it will snapshot the current
|
||||
budget: every entry, its planned amount, and the current Primary Debt
|
||||
Target. Applied amounts start at $0.00.
|
||||
</p>
|
||||
<form
|
||||
hx-post="/month/{{ year_month }}/create"
|
||||
hx-swap="none"
|
||||
class="month-create-form"
|
||||
>
|
||||
<button type="submit">Create {{ year_month }}</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
22
src/quartermaster/templates/partials/month_nav.html
Normal file
22
src/quartermaster/templates/partials/month_nav.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<nav class="month-nav">
|
||||
<a class="nav-link" href="/month/{{ prev_year_month }}" aria-label="Previous month">← {{ prev_year_month }}</a>
|
||||
<span class="month-label">{{ year_month }}</span>
|
||||
<a class="nav-link" href="/month/{{ next_year_month }}" aria-label="Next month">{{ next_year_month }} →</a>
|
||||
<span class="spacer"></span>
|
||||
{% if all_months %}
|
||||
<select
|
||||
class="month-picker"
|
||||
onchange="if(this.value){window.location=this.value}"
|
||||
aria-label="Jump to month"
|
||||
>
|
||||
<option value="">Jump to...</option>
|
||||
{% for m in all_months %}
|
||||
<option
|
||||
value="/month/{{ m.year_month }}"
|
||||
{% if m.year_month == year_month %}selected{% endif %}
|
||||
>{{ m.year_month }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
<a class="nav-link" href="/">Budget config</a>
|
||||
</nav>
|
||||
91
src/quartermaster/templates/partials/month_section.html
Normal file
91
src/quartermaster/templates/partials/month_section.html
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<section class="section" id="section-{{ section.section.value }}">
|
||||
<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="divider">/</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>
|
||||
{% for row in section.rows %}
|
||||
<tr class="entry state-{{ row.state.value }}" data-entry-id="{{ row.entry.id }}">
|
||||
<td class="entry-name">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value="{{ row.entry.name }}"
|
||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||
hx-trigger="change"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
{% 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">
|
||||
<input
|
||||
type="number" step="0.01" min="0"
|
||||
name="planned"
|
||||
value="{{ '%.2f' | format(row.entry.planned) }}"
|
||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||
hx-trigger="change"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
</td>
|
||||
<td class="entry-amount">
|
||||
<input
|
||||
type="number" step="0.01" min="0"
|
||||
name="applied"
|
||||
value="{{ '%.2f' | format(row.entry.applied) }}"
|
||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||
hx-trigger="change"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
</td>
|
||||
<td class="entry-actions">
|
||||
<button
|
||||
class="delete"
|
||||
type="button"
|
||||
hx-delete="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
aria-label="Delete {{ row.entry.name }}"
|
||||
>×</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty"><td colspan="4">No entries.</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="add-row">
|
||||
<td colspan="4">
|
||||
<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>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
46
src/quartermaster/templates/partials/month_target.html
Normal file
46
src/quartermaster/templates/partials/month_target.html
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<section class="section target-section" id="section-debt_target" hx-swap-oob="outerHTML">
|
||||
<div class="section-header">
|
||||
<h2>Primary Debt Target</h2>
|
||||
{% if target.entry %}
|
||||
<span class="total">
|
||||
<span class="applied">${{ '%.2f' | format(target.entry.applied) }}</span>
|
||||
<span class="divider">/</span>
|
||||
<span class="planned">${{ '%.2f' | format(target.entry.planned) }}</span>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="total empty">$0.00</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<table class="entries">
|
||||
<tbody>
|
||||
<tr class="entry">
|
||||
<td class="entry-name">
|
||||
{% if target.entry %}{{ target.entry.name }}{% else %}<span class="muted">No target selected.</span>{% endif %}
|
||||
</td>
|
||||
<td class="entry-amount"></td>
|
||||
<td class="entry-actions"></td>
|
||||
</tr>
|
||||
<tr class="add-row">
|
||||
<td colspan="3">
|
||||
<form
|
||||
class="target-form"
|
||||
hx-post="/month/{{ month.year_month }}/target"
|
||||
hx-target="#section-debt_target"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<select name="month_entry_id">
|
||||
<option value="">(none)</option>
|
||||
{% for dm in debt_minimums %}
|
||||
<option
|
||||
value="{{ dm.id }}"
|
||||
{% if target.month_entry_id == dm.id %}selected{% endif %}
|
||||
>{{ dm.name }}: ${{ '%.2f' | format(dm.planned) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Set</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
139
tests/test_month_routes.py
Normal file
139
tests/test_month_routes.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
from __future__ import annotations
|
||||
|
||||
|
||||
def _seed_budget_via_api(client):
|
||||
client.post(
|
||||
"/sections/income/entries",
|
||||
data={"name": "Paycheck", "amount": "2500.00"},
|
||||
)
|
||||
client.post(
|
||||
"/sections/fixed_bill/entries",
|
||||
data={"name": "Rent", "amount": "1200.00"},
|
||||
)
|
||||
client.post(
|
||||
"/sections/debt_minimum/entries",
|
||||
data={"name": "Card A", "amount": "40.00"},
|
||||
)
|
||||
|
||||
|
||||
def test_missing_month_renders_create_flow(client):
|
||||
response = client.get("/month/2026-04")
|
||||
assert response.status_code == 200
|
||||
assert "No snapshot yet" in response.text
|
||||
assert 'hx-post="/month/2026-04/create"' in response.text
|
||||
|
||||
|
||||
def test_create_month_redirects_via_htmx(client):
|
||||
_seed_budget_via_api(client)
|
||||
response = client.post("/month/2026-04/create")
|
||||
assert response.status_code == 204
|
||||
assert response.headers.get("hx-redirect") == "/month/2026-04"
|
||||
|
||||
|
||||
def test_created_month_renders_snapshot(client):
|
||||
_seed_budget_via_api(client)
|
||||
client.post("/month/2026-04/create")
|
||||
response = client.get("/month/2026-04")
|
||||
assert response.status_code == 200
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
response = client.post(
|
||||
"/month/2026-04/entries/2", data={"applied": "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
|
||||
|
||||
|
||||
def test_name_edit_flips_to_modified(client):
|
||||
_seed_budget_via_api(client)
|
||||
client.post("/month/2026-04/create")
|
||||
response = client.post(
|
||||
"/month/2026-04/entries/2", data={"name": "Rent (April)"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "state-edited" in response.text
|
||||
assert 'class="tag tag-edited">modified' in response.text
|
||||
|
||||
|
||||
def test_add_new_entry_within_month_is_marked_new(client):
|
||||
_seed_budget_via_api(client)
|
||||
client.post("/month/2026-04/create")
|
||||
response = client.post(
|
||||
"/month/2026-04/sections/other/entries",
|
||||
data={"name": "Gift", "planned": "50.00"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "state-new_in_month" in response.text
|
||||
assert "new this month" in response.text
|
||||
|
||||
|
||||
def test_delete_month_entry(client):
|
||||
_seed_budget_via_api(client)
|
||||
client.post("/month/2026-04/create")
|
||||
response = client.delete("/month/2026-04/entries/2")
|
||||
assert response.status_code == 200
|
||||
assert "Rent" not in response.text
|
||||
|
||||
|
||||
def test_delete_month_debt_minimum_updates_target(client):
|
||||
_seed_budget_via_api(client)
|
||||
client.post("/month/2026-04/create")
|
||||
# Card A is entry id 3 in the month snapshot
|
||||
response = client.delete("/month/2026-04/entries/3")
|
||||
assert response.status_code == 200
|
||||
assert "section-debt_target" in response.text
|
||||
assert "No target selected" in response.text
|
||||
|
||||
|
||||
def test_set_month_target(client):
|
||||
_seed_budget_via_api(client)
|
||||
client.post(
|
||||
"/sections/debt_minimum/entries",
|
||||
data={"name": "Card B", "amount": "60.00"},
|
||||
)
|
||||
client.post("/month/2026-04/create")
|
||||
# Card B is month_entry id 4
|
||||
response = client.post(
|
||||
"/month/2026-04/target", data={"month_entry_id": "4"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Card B" in response.text
|
||||
|
||||
|
||||
def test_month_target_isolated_between_months(client):
|
||||
_seed_budget_via_api(client)
|
||||
client.post(
|
||||
"/sections/debt_minimum/entries",
|
||||
data={"name": "Card B", "amount": "60.00"},
|
||||
)
|
||||
client.post("/month/2026-04/create")
|
||||
client.post("/month/2026-05/create")
|
||||
# Change April target to Card B (month_entry id 4 within April)
|
||||
client.post("/month/2026-04/target", data={"month_entry_id": "4"})
|
||||
may_page = client.get("/month/2026-05")
|
||||
# May still points at Card A (copied from budget)
|
||||
assert "Card A" in may_page.text
|
||||
|
||||
|
||||
def test_malformed_year_month_returns_404(client):
|
||||
response = client.get("/month/2026-13")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_budget_page_shows_month_nav(client):
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "This month" in response.text
|
||||
148
tests/test_month_service.py
Normal file
148
tests/test_month_service.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from quartermaster import month_service, service
|
||||
from quartermaster.models import Section
|
||||
|
||||
|
||||
def _seed_budget(db):
|
||||
income = service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00"))
|
||||
rent = service.add_entry(
|
||||
db, Section.fixed_bill, "Rent", Decimal("1200.00")
|
||||
)
|
||||
card_a = service.add_entry(
|
||||
db, Section.debt_minimum, "Card A", Decimal("40.00")
|
||||
)
|
||||
card_b = service.add_entry(
|
||||
db, Section.debt_minimum, "Card B", Decimal("60.00")
|
||||
)
|
||||
service.set_debt_target(db, card_a.id)
|
||||
return income, rent, card_a, card_b
|
||||
|
||||
|
||||
def test_valid_year_month():
|
||||
assert month_service.valid_year_month("2026-04")
|
||||
assert month_service.valid_year_month("2026-12")
|
||||
assert not month_service.valid_year_month("2026-00")
|
||||
assert not month_service.valid_year_month("2026-13")
|
||||
assert not month_service.valid_year_month("26-04")
|
||||
assert not month_service.valid_year_month("2026/04")
|
||||
|
||||
|
||||
def test_shift_year_month_rolls_years():
|
||||
assert month_service.shift_year_month("2026-12", 1) == "2027-01"
|
||||
assert month_service.shift_year_month("2026-01", -1) == "2025-12"
|
||||
assert month_service.shift_year_month("2026-04", 0) == "2026-04"
|
||||
assert month_service.shift_year_month("2026-04", 12) == "2027-04"
|
||||
|
||||
|
||||
def test_create_month_snapshots_budget(db):
|
||||
income, rent, card_a, card_b = _seed_budget(db)
|
||||
month = month_service.create_month(db, "2026-04")
|
||||
assert month.year_month == "2026-04"
|
||||
assert len(month.entries) == 4
|
||||
by_source = {e.source_entry_id: e for e in month.entries}
|
||||
assert by_source[income.id].planned == Decimal("2500.00")
|
||||
assert by_source[income.id].origin_name == "Paycheck"
|
||||
assert by_source[income.id].origin_planned == Decimal("2500.00")
|
||||
assert by_source[income.id].applied == Decimal("0.00")
|
||||
assert by_source[rent.id].section == Section.fixed_bill
|
||||
assert month.target is not None
|
||||
assert month.target.entry is not None
|
||||
assert month.target.entry.source_entry_id == card_a.id
|
||||
|
||||
|
||||
def test_create_month_idempotent(db):
|
||||
_seed_budget(db)
|
||||
first = month_service.create_month(db, "2026-04")
|
||||
second = month_service.create_month(db, "2026-04")
|
||||
assert first.id == second.id
|
||||
|
||||
|
||||
def test_create_month_rejects_bad_year_month(db):
|
||||
with pytest.raises(ValueError):
|
||||
month_service.create_month(db, "2026-00")
|
||||
|
||||
|
||||
def test_deviation_state_reflects_edits(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")
|
||||
assert month_service.deviation_state(rent) == month_service.DeviationState.unchanged
|
||||
|
||||
month_service.update_month_entry(
|
||||
db, month, rent.id, planned=Decimal("1300.00")
|
||||
)
|
||||
rent = next(e for e in month.entries if e.id == rent.id)
|
||||
assert month_service.deviation_state(rent) == month_service.DeviationState.edited
|
||||
|
||||
|
||||
def test_added_row_is_new_in_month(db):
|
||||
_seed_budget(db)
|
||||
month = month_service.create_month(db, "2026-04")
|
||||
extra = month_service.add_month_entry(
|
||||
db, month, Section.other, "Surprise", Decimal("20.00")
|
||||
)
|
||||
assert extra.origin_name is None
|
||||
assert month_service.deviation_state(extra) == (
|
||||
month_service.DeviationState.new_in_month
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
)
|
||||
view = month_service.section_view(month, Section.fixed_bill, "Fixed")
|
||||
assert view.total_applied == Decimal("1200.00")
|
||||
assert view.total_planned == Decimal("1200.00")
|
||||
|
||||
|
||||
def test_per_month_target_is_isolated(db):
|
||||
_seed_budget(db)
|
||||
april = month_service.create_month(db, "2026-04")
|
||||
may = month_service.create_month(db, "2026-05")
|
||||
april_b = next(
|
||||
e for e in april.entries
|
||||
if e.section == Section.debt_minimum and e.origin_name == "Card B"
|
||||
)
|
||||
month_service.set_month_target(db, april, april_b.id)
|
||||
april_target = month_service.get_month_target(db, april)
|
||||
may_target = month_service.get_month_target(db, may)
|
||||
assert april_target.entry is not None
|
||||
assert april_target.entry.origin_name == "Card B"
|
||||
assert may_target.entry is not None
|
||||
assert may_target.entry.origin_name == "Card A"
|
||||
|
||||
|
||||
def test_delete_month_entry_returns_section(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")
|
||||
result = month_service.delete_month_entry(db, month, rent.id)
|
||||
assert result == Section.fixed_bill
|
||||
db.refresh(month)
|
||||
remaining = [
|
||||
e for e in month.entries if e.section == Section.fixed_bill
|
||||
]
|
||||
assert remaining == []
|
||||
|
||||
|
||||
def test_budget_entry_deleted_after_snapshot_leaves_month_intact(db):
|
||||
_seed_budget(db)
|
||||
month = month_service.create_month(db, "2026-04")
|
||||
# remove budget-side rent; month-side rent should still render unchanged
|
||||
budget_rent = service.list_entries(db, Section.fixed_bill)[0]
|
||||
service.delete_entry(db, budget_rent.id)
|
||||
db.expire_all()
|
||||
month_rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||
assert month_rent.source_entry_id is None
|
||||
assert month_service.deviation_state(month_rent) == (
|
||||
month_service.DeviationState.unchanged
|
||||
)
|
||||
Loading…
Reference in a new issue