Compare commits

...

3 commits

37 changed files with 2303 additions and 1 deletions

View file

@ -0,0 +1,20 @@
You are a lead/staff/senior architect/software engineer.
All of your code adheres to the SOLID, KISS, COMMAND/QUERY, and Law of Demeter principles.
If SPECS.md exists, it is the source of truth for this application. Read it NOW.
If SPECS.md doesn't exist, follow the below rules.
- Your job is to come up with an application to build.
- Any application will do. It is completely up to you. There are no rules on WHAT you can build.
- The only rules are:
- Whatever you decide to build, you will first create an application specification file for it called, SPECS.md.
- SPECS.md does not have to be built in one go. Do it little by little.
- As soon as the decision is made what to build, write that decision in SPECS.md
- SPECS.md must contain as much detail as possible about the application, including the following tech stack
- SQLite
- languages/frameworks/tools are completely up to you, but they must be specified in SPECS.md
- As more decisions are made, write them in SPECS.md.
- Once SPECS.md is completed, you will write the code to build the application.
- Do not try to write the code all at once, write it per one to five files. Not entire application in one execution.
- It must be fully tested at a code coverage of 95% or greater of application code.
- Any pre-written framework files or tool files are excluded from this coverage.

BIN
.coverage Normal file

Binary file not shown.

2
.gitignore vendored
View file

@ -158,5 +158,5 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.idea/

47
README.md Normal file
View file

@ -0,0 +1,47 @@
# Ledgerly
A tiny, zero-dependency personal finance tracker for the command line.
Record income and expense transactions, set a monthly budget per expense
category, and report where the money went. All data lives in one SQLite file;
the runtime needs nothing but the Python standard library.
See [SPECS.md](SPECS.md) — the source of truth for this application.
## Quick start
```bash
python -m ledgerly add 45.20 --category Groceries --note "weekly shop"
python -m ledgerly add 3000 --category Salary --date 2026-10-01
python -m ledgerly budget-set Rent 1500
python -m ledgerly list
python -m ledgerly budget-show --month 2026-10
python -m ledgerly report --month 2026-10
```
Data is stored in `~/.ledgerly/ledgerly.db` (override with `--db PATH`,
a global option that goes *before* the subcommand).
## Commands
| Command | Purpose |
|---|---|
| `add AMOUNT --category NAME [--date YYYY-MM-DD] [--note TEXT]` | record a transaction |
| `list [--month YYYY-MM] [--category NAME] [--all]` | list transactions (newest first) |
| `delete ID` | delete a transaction |
| `categories` | list categories |
| `category-add NAME --type income\|expense` | add a category |
| `category-del NAME` | delete an unused category |
| `budget-set CATEGORY AMOUNT` | set/replace the monthly budget |
| `budget-del CATEGORY` | remove a budget |
| `budget-show [--month YYYY-MM]` | budgets vs. spending (flags `OVER`, surfaces unbudgeted spending) |
| `report [--month YYYY-MM]` | income / expenses / net + category breakdown |
Exit codes: `0` success · `1` domain error · `2` usage error.
## Development
```bash
python -m pytest # run the test suite
python -m coverage run -m pytest # run with coverage
python -m coverage report # report (gate: 95% of ledgerly/)
```

188
SPECS.md Normal file
View file

@ -0,0 +1,188 @@
# Ledgerly — Application Specification
Ledgerly is a single-user, command-line personal finance tracker. It records
income and expense transactions against named categories, keeps a monthly
budget per expense category, and reports monthly totals, per-category totals,
and budget status. All state lives in one SQLite file.
Status: **active — this file is the source of truth for the application.**
---
## 1. Goals
- G1 Zero runtime dependencies beyond the Python standard library.
- G2 All money math exact: integer cents; no floats in storage or domain logic.
- G3 Plain-text output that is stable and script-friendly.
- G4 Domain logic fully tested: ≥ 95% line coverage of `ledgerly/` application code.
## 2. Non-goals (v0.1)
- Web UI, authentication, multi-user.
- Multi-currency, taxes, transfers between accounts.
- Recurring transactions, reminders.
## 3. Tech stack
| Concern | Choice |
|-------------|-------------------------------------------|
| Language | Python 3.13 (supports 3.10+) |
| Storage | SQLite (≥ 3.40) via stdlib `sqlite3` |
| CLI | stdlib `argparse` (subcommands) |
| Tests | pytest 8.x |
| Coverage | coverage.py 7.x, gate at 95% |
| Packaging | PEP 621 `pyproject.toml`, no install req. |
## 4. Domain model
### 4.1 Entities
- **Category** — `name` (unique, case-insensitive, ≤ 50 chars), `kind` ∈ {income, expense}.
- **Transaction** — `id` (auto), `amount` (> 0, integer cents), `category` (must exist),
`date` (YYYY-MM-DD, 2000-01-01…2100-12-31), `note` (≤ 200 chars, optional).
- **Budget** — monthly limit for one expense category: `category` + `amount` (> 0).
A budget is defined once and applies to every month.
### 4.2 Value objects / read models
- **Money** — immutable, total-ordered, stored as signed integer cents.
`Money.parse()` accepts decimal input (≤ 2 decimals, half-up rounding).
Formats as `$1,234.56`; negatives as `-$1.00`.
- **Month** — `(year, month)` with year in 2000…2100; `Month.from_str("YYYY-MM")`;
exposes `first_day`, `last_day`, `range`.
- **MonthSummary** — month, income, expenses, `net` (= income − expenses, may be negative).
- **CategoryTotal** — category, kind, total for a period.
- **BudgetLine** — category, budget, spent; `remaining` (may be negative), `is_over`.
### 4.3 Rules
| ID | Rule |
|-----|------|
| R1 | Transaction amount must be > 0. |
| R2 | Budget amount must be > 0. |
| R3 | Amounts have at most 2 decimal places (parsed, half-up rounded). |
| R4 | Dates are valid ISO, between 2000-01-01 and 2100-12-31. |
| R5 | Category names: non-empty, ≤ 50 chars, unique case-insensitively. |
| R6 | Category kind ∈ {income, expense}. |
| R7 | Budgets may only attach to expense categories. |
| R8 | Transactions must reference an existing category. |
| R9 | A category that has transactions or a budget cannot be deleted. |
| R10 | Note ≤ 200 chars; blank notes are stored as NULL. |
| R11 | Budget report lists every budgeted expense category for the month, plus any expense category with spending but no budget (budget shown as $0). |
| R12 | Deleting a transaction requires its id; unknown id is an error. |
## 5. Architecture
Principles: SOLID, KISS, CQRS, Law of Demeter.
- **DIP / SRP** — services depend on a `Repository` protocol, not on SQLite.
One module per concern:
- `money.py` — Money value object and parsing.
- `models.py` — entities, value objects, input sanitizers (name/note/date/month).
- `db.py` — schema DDL and connection factory.
- `repository.py` — `Repository` protocol + `SqliteRepository` (only module that speaks SQL).
- `services/ledger.py` — category & transaction commands/queries.
- `services/budgets.py` — budget commands/queries.
- `services/reports.py` — read-only reporting queries.
- `cli.py` — argument parsing, dispatch, presentation (strings in, strings out).
- `__main__.py` — process entry point (`python -m ledgerly`).
- **CQRS** — every service method is either a command (state change) or a query
(read-only). Commands never return read-model aggregates; queries never mutate.
- **Law of Demeter** — call chain is strictly CLI → service → repository;
each layer sees only the next.
- **Errors** — `LedgerlyError` base with `ValidationError`, `NotFoundError`,
`ConflictError`. The CLI maps them to exit codes; no string matching.
## 6. Persistence
SQLite file with foreign keys enforced. Default location `~/.ledgerly/ledgerly.db`;
overridable with `--db PATH` (global option). Tests use `:memory:` or a temp file.
```sql
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
kind TEXT NOT NULL CHECK (kind IN ('income', 'expense'))
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
category_id INTEGER NOT NULL REFERENCES categories (id),
date TEXT NOT NULL, -- YYYY-MM-DD
note TEXT
);
CREATE INDEX IF NOT EXISTS idx_tx_date ON transactions (date);
CREATE INDEX IF NOT EXISTS idx_tx_category ON transactions (category_id);
CREATE TABLE IF NOT EXISTS budgets (
category_id INTEGER PRIMARY KEY REFERENCES categories (id),
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0)
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
```
First run seeds (exactly once — a `meta` marker row records that seeding
happened, so deleted categories never resurrect):
income `Salary`, `Other Income`; expense `Rent`, `Groceries`, `Utilities`,
`Dining Out`, `Transport`, `Entertainment`, `Health`, `Savings`, `Other`.
## 7. CLI reference
Global options: `--db PATH` (default `~/.ledgerly/ledgerly.db`), `--version`.
| Command | Options | Effect |
|----------------------|---------------------------------------------------------------------|--------|
| `add AMOUNT` | `--category NAME` (req), `--date YYYY-MM-DD` (default today), `--note TEXT` | record a transaction (R1, R3, R8, R10) |
| `list` | `--month YYYY-MM` (default current), `--category NAME`, `--all` | list transactions, newest first (R5, R8) |
| `delete ID` | — | delete a transaction (R12) |
| `categories` | — | list categories |
| `category-add NAME` | `--type income\|expense` (req) | add a category (R5, R6) |
| `category-del NAME` | — | delete an unused category (R9) |
| `budget-set CATEGORY AMOUNT` | — | set/replace the monthly budget (R2, R7) |
| `budget-del CATEGORY`| — | remove a budget |
| `budget-show` | `--month YYYY-MM` (default current) | budgets vs. spending for the month (R11) |
| `report` | `--month YYYY-MM` (default current) | monthly summary + category breakdown |
Exit codes: **0** success · **1** domain error (`error: …` on stderr) ·
**2** usage/parsing error (argparse).
## 8. Testing & coverage
- pytest; one test module per source module.
- Persistence exercised through `SqliteRepository` on `:memory:`.
- Services tested against the real repository (the protocol is the contract;
the real thing is the fixture).
- CLI tested by calling `main(argv, db_path=tmp)` and asserting captured
stdout/stderr and exit codes.
- Gate: `coverage run -m pytest` with `[tool.coverage.report] fail_under = 95`
over `ledgerly/`. Excluded: `ledgerly/__main__.py` (2-line entry tool file);
`tests/` and stdlib are not application code.
## 9. Milestones
- M1 — this spec.
- M2 — core value objects (`money.py`, `models.py`) + errors.
- M3 — schema + repository (`db.py`, `repository.py`).
- M4 — services (`services/ledger.py`, `services/budgets.py`, `services/reports.py`).
- M5 — CLI, entry point, `pyproject.toml`, README.
- M6 — test suite; coverage gate green.
## 10. Decisions log
- D1 Integer cents over floats: exactness, SQL CHECK constraints, no drift.
- D2 Stdlib-only runtime: KISS, zero-install, nothing to pin.
- D3 Budgets are per-category and month-agnostic (apply every month) —
simplest mental model; month is a reporting axis, not a budget axis (R11).
- D4 Month is the only time filter in v0.1 (KISS).
- D5 Category names are case-insensitively unique; stored casing is preserved
(UNIQUE COLLATE NOCASE + `COLLATE NOCASE` lookups).
- D6 Unbudgeted spending surfaces with budget $0 (R11) — no silent leaks.
- D7 CLI handlers return strings; `main()` is the only place that prints
(keeps the CLI trivially testable).

5
ledgerly/__init__.py Normal file
View file

@ -0,0 +1,5 @@
"""Ledgerly — a tiny personal finance tracker (transactions, budgets, reports)."""
__version__ = "0.1.0"
__all__ = ["__version__"]

8
ledgerly/__main__.py Normal file
View file

@ -0,0 +1,8 @@
"""Entry point for ``python -m ledgerly``."""
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

252
ledgerly/cli.py Normal file
View file

@ -0,0 +1,252 @@
"""Command line interface: parsing, dispatch, and text presentation.
Handlers stay pure with respect to I/O: each returns the text to print (or
None). ``main`` is the only place that talks to stdout/stderr (D7), which
keeps the CLI trivially testable.
"""
from __future__ import annotations
import argparse
import sys
from datetime import date
from pathlib import Path
from typing import Callable, Sequence
from . import __version__
from .errors import LedgerlyError, ValidationError
from .money import InvalidMoney, Money
from .models import EXPENSE, INCOME, Month, Transaction, parse_date
from .repository import SqliteRepository
from .services import BudgetService, LedgerService, ReportingService
__all__ = ["build_parser", "default_db_path", "main"]
def default_db_path() -> Path:
return Path.home() / ".ledgerly" / "ledgerly.db"
# -- argument types ----------------------------------------------------------
def money_arg(text: str) -> Money:
try:
return Money.parse(text)
except InvalidMoney as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
def date_arg(text: str) -> date:
try:
return parse_date(text)
except ValidationError as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
def month_arg(text: str) -> Month:
try:
return Month.from_str(text)
except ValidationError as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
# -- parser -------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="ledgerly",
description=(
"A tiny personal finance tracker: transactions, budgets, "
"monthly reports."
),
)
parser.add_argument(
"--version", action="version", version=f"ledgerly {__version__}"
)
parser.add_argument(
"--db",
default=None,
metavar="PATH",
help=f"database file (default: {default_db_path()})",
)
sub = parser.add_subparsers(dest="command", required=True)
add = sub.add_parser("add", help="record a transaction")
add.add_argument("amount", type=money_arg)
add.add_argument("--category", required=True)
add.add_argument("--date", type=date_arg, default=None, help="YYYY-MM-DD (default: today)")
add.add_argument("--note", default=None)
lst = sub.add_parser("list", help="list transactions")
lst.add_argument("--month", type=month_arg, default=None, help="YYYY-MM (default: current)")
lst.add_argument("--category", default=None)
lst.add_argument("--all", action="store_true", help="ignore the month filter")
delete = sub.add_parser("delete", help="delete a transaction by id")
delete.add_argument("id", type=int)
sub.add_parser("categories", help="list categories")
cat_add = sub.add_parser("category-add", help="add a category")
cat_add.add_argument("name")
cat_add.add_argument("--type", dest="kind", required=True, choices=[INCOME, EXPENSE])
cat_del = sub.add_parser("category-del", help="delete an unused category")
cat_del.add_argument("name")
budget_set = sub.add_parser("budget-set", help="set the monthly budget for an expense category")
budget_set.add_argument("category")
budget_set.add_argument("amount", type=money_arg)
budget_del = sub.add_parser("budget-del", help="remove a budget")
budget_del.add_argument("category")
budget_show = sub.add_parser("budget-show", help="budgets vs. spending for a month")
budget_show.add_argument("--month", type=month_arg, default=None, help="YYYY-MM (default: current)")
report = sub.add_parser("report", help="monthly summary and category breakdown")
report.add_argument("--month", type=month_arg, default=None, help="YYYY-MM (default: current)")
return parser
# -- command handlers (return the text to print) ------------------------------
def _cmd_add(args: argparse.Namespace, repo: SqliteRepository) -> str:
service = LedgerService(repo)
day = args.date or date.today()
tx = service.add_transaction(args.amount, args.category, day, args.note)
note = f" ({tx.note})" if tx.note else ""
return (
f"recorded #{tx.id}: {tx.amount} in {tx.category} "
f"on {tx.date.isoformat()}{note}"
)
def _cmd_list(args: argparse.Namespace, repo: SqliteRepository) -> str:
service = LedgerService(repo)
if args.all:
start = end = None
else:
start, end = (args.month or Month.now()).range
rows = service.list_transactions(start, end, args.category)
return _render_transactions(rows)
def _render_transactions(rows: list[Transaction]) -> str:
if not rows:
return "no transactions"
out = []
for tx in rows:
line = f"#{tx.id:<4} {tx.date.isoformat()} {tx.category:<16} {tx.kind:<8} {tx.amount}"
if tx.note:
line += f" [{tx.note}]"
out.append(line)
return "\n".join(out)
def _cmd_delete(args: argparse.Namespace, repo: SqliteRepository) -> str:
LedgerService(repo).delete_transaction(args.id)
return f"deleted transaction #{args.id}"
def _cmd_categories(args: argparse.Namespace, repo: SqliteRepository) -> str:
cats = LedgerService(repo).list_categories()
if not cats:
return "no categories"
return "\n".join(f"{cat.kind:<8} {cat.name}" for cat in cats)
def _cmd_category_add(args: argparse.Namespace, repo: SqliteRepository) -> str:
cat = LedgerService(repo).add_category(args.name, args.kind)
return f"added {cat.kind} category {cat.name!r}"
def _cmd_category_del(args: argparse.Namespace, repo: SqliteRepository) -> str:
LedgerService(repo).delete_category(args.name)
return f"deleted category {args.name!r}"
def _cmd_budget_set(args: argparse.Namespace, repo: SqliteRepository) -> str:
budget = BudgetService(repo).set_budget(args.category, args.amount)
return f"budget for {budget.category}: {budget.amount} per month"
def _cmd_budget_del(args: argparse.Namespace, repo: SqliteRepository) -> str:
budget = BudgetService(repo).delete_budget(args.category)
return f"removed budget for {budget.category}: {budget.amount}"
def _cmd_budget_show(args: argparse.Namespace, repo: SqliteRepository) -> str:
month = args.month or Month.now()
lines = ReportingService(repo).budget_status(month)
out = [f"budgets for {month.label}"]
if not lines:
out.append("no budgets set, no unbudgeted spending")
for line in lines:
marker = " OVER" if line.is_over else ""
out.append(
f"{line.category:<16} budget {line.budget:<12} "
f"spent {line.spent:<12} left {line.remaining}{marker}"
)
return "\n".join(out)
def _cmd_report(args: argparse.Namespace, repo: SqliteRepository) -> str:
month = args.month or Month.now()
service = ReportingService(repo)
summary = service.monthly_summary(month)
out = [
f"report for {month.label}",
f" income {summary.income}",
f" expenses {summary.expenses}",
f" net {summary.net}",
]
totals = service.category_totals(month)
if not totals:
out.append(" no transactions this month")
else:
current_kind = None
for total in totals:
if total.kind != current_kind:
out.append(f" {total.kind}")
current_kind = total.kind
out.append(f" {total.category:<16} {total.total}")
return "\n".join(out)
HANDLERS: dict[str, Callable[[argparse.Namespace, SqliteRepository], str]] = {
"add": _cmd_add,
"list": _cmd_list,
"delete": _cmd_delete,
"categories": _cmd_categories,
"category-add": _cmd_category_add,
"category-del": _cmd_category_del,
"budget-set": _cmd_budget_set,
"budget-del": _cmd_budget_del,
"budget-show": _cmd_budget_show,
"report": _cmd_report,
}
# -- entry point ---------------------------------------------------------------
def main(argv: Sequence[str] | None = None, *, db_path: str | Path | None = None) -> int:
"""Run the CLI. Returns a process exit code (SPECS.md §7)."""
args = build_parser().parse_args(argv)
path = str(db_path) if db_path is not None else str(args.db or default_db_path())
repo = SqliteRepository(path)
try:
output = HANDLERS[args.command](args, repo)
except LedgerlyError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
finally:
repo.close()
if output:
print(output)
return 0

74
ledgerly/db.py Normal file
View file

@ -0,0 +1,74 @@
"""SQLite schema and connection factory (SPECS.md §6)."""
from __future__ import annotations
import sqlite3
from pathlib import Path
from .models import DEFAULT_CATEGORIES
__all__ = ["SCHEMA", "connect", "seed_default_categories"]
SCHEMA = """
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
kind TEXT NOT NULL CHECK (kind IN ('income', 'expense'))
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
category_id INTEGER NOT NULL REFERENCES categories (id),
date TEXT NOT NULL,
note TEXT
);
CREATE INDEX IF NOT EXISTS idx_tx_date ON transactions (date);
CREATE INDEX IF NOT EXISTS idx_tx_category ON transactions (category_id);
CREATE TABLE IF NOT EXISTS budgets (
category_id INTEGER PRIMARY KEY REFERENCES categories (id),
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0)
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
"""
def connect(db_path: str | Path) -> sqlite3.Connection:
"""Open (and initialize) a SQLite database at *db_path*.
Creates parent directories for file databases. ``:memory:`` is allowed
for tests.
"""
path = str(db_path)
if path != ":memory:":
parent = Path(path).expanduser().parent
if parent not in (Path(""), Path(".")):
parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.executescript(SCHEMA)
return conn
def seed_default_categories(conn: sqlite3.Connection) -> None:
"""Seed built-in categories on first run only (SPECS.md §6).
A marker row in ``meta`` records that seeding happened, so deleted
categories do not resurrect even if the user deletes them all.
"""
marker = conn.execute("SELECT 1 FROM meta WHERE key = 'seeded'").fetchone()
if marker is not None:
return
conn.executemany(
"INSERT OR IGNORE INTO categories (name, kind) VALUES (?, ?)",
DEFAULT_CATEGORIES,
)
conn.execute("INSERT INTO meta (key, value) VALUES ('seeded', '1')")
conn.commit()

24
ledgerly/errors.py Normal file
View file

@ -0,0 +1,24 @@
"""Application error hierarchy.
Every expected failure is a subclass of :class:`LedgerlyError`, so the CLI
layer can translate failures into stable exit codes instead of inspecting
message text (single source of truth for error semantics).
"""
__all__ = ["ConflictError", "LedgerlyError", "NotFoundError", "ValidationError"]
class LedgerlyError(Exception):
"""Base class for all expected Ledgerly failures."""
class ValidationError(LedgerlyError):
"""Input does not satisfy a domain rule (R1-R10)."""
class NotFoundError(LedgerlyError):
"""The requested entity does not exist (R8, R12)."""
class ConflictError(LedgerlyError):
"""The request conflicts with current state (e.g. a category in use, R9)."""

211
ledgerly/models.py Normal file
View file

@ -0,0 +1,211 @@
"""Domain entities, value objects, and input sanitizers (SPECS.md §4)."""
from __future__ import annotations
import calendar
from dataclasses import dataclass
from datetime import date
from .errors import ValidationError
from .money import Money
__all__ = [
"EXPENSE",
"INCOME",
"KINDS",
"DEFAULT_CATEGORIES",
"Budget",
"BudgetLine",
"Category",
"CategoryTotal",
"Month",
"MonthSummary",
"Transaction",
"clean_name",
"clean_note",
"parse_date",
"validate_kind",
]
INCOME = "income"
EXPENSE = "expense"
KINDS = (INCOME, EXPENSE)
MAX_NAME_LEN = 50
MAX_NOTE_LEN = 200
DATE_MIN = date(2000, 1, 1)
DATE_MAX = date(2100, 12, 31)
YEAR_MIN = 2000
YEAR_MAX = 2100
#: Categories seeded on first run (SPECS.md §6).
DEFAULT_CATEGORIES: tuple[tuple[str, str], ...] = (
("Salary", INCOME),
("Other Income", INCOME),
("Rent", EXPENSE),
("Groceries", EXPENSE),
("Utilities", EXPENSE),
("Dining Out", EXPENSE),
("Transport", EXPENSE),
("Entertainment", EXPENSE),
("Health", EXPENSE),
("Savings", EXPENSE),
("Other", EXPENSE),
)
def clean_name(text: str | None, field: str = "name") -> str:
"""Validate a category name (R5)."""
if text is None:
raise ValidationError(f"{field} must not be empty")
name = str(text).strip()
if not name:
raise ValidationError(f"{field} must not be empty")
if len(name) > MAX_NAME_LEN:
raise ValidationError(f"{field} must be at most {MAX_NAME_LEN} characters")
return name
def clean_note(text: str | None) -> str | None:
"""Validate a transaction note (R10); blank notes become None."""
if text is None:
return None
note = str(text).strip()
if not note:
return None
if len(note) > MAX_NOTE_LEN:
raise ValidationError(f"note must be at most {MAX_NOTE_LEN} characters")
return note
def parse_date(text: str) -> date:
"""Validate an ISO date string (R4)."""
try:
parsed = date.fromisoformat(str(text).strip())
except (TypeError, ValueError) as exc:
raise ValidationError(f"invalid date {text!r}, expected YYYY-MM-DD") from exc
if not DATE_MIN <= parsed <= DATE_MAX:
raise ValidationError(
f"date must be between {DATE_MIN.isoformat()} and {DATE_MAX.isoformat()}"
)
return parsed
def validate_kind(kind: str) -> str:
"""Validate a category kind (R6)."""
if kind not in KINDS:
raise ValidationError(f"kind must be one of {', '.join(KINDS)}")
return kind
@dataclass(frozen=True)
class Category:
"""A named bucket that classifies transactions (R5, R6)."""
name: str
kind: str
@dataclass(frozen=True)
class Transaction:
"""A single amount of money that came in or went out (R1, R4, R8, R10)."""
id: int
amount: Money
category: str
kind: str
date: date
note: str | None = None
@dataclass(frozen=True)
class Budget:
"""A monthly limit for one expense category (R2, R7)."""
category: str
amount: Money
@dataclass(frozen=True)
class Month:
"""A calendar month; the reporting time axis (SPECS.md §4.2)."""
year: int
month: int
def __post_init__(self) -> None:
if not YEAR_MIN <= self.year <= YEAR_MAX:
raise ValidationError(f"year must be between {YEAR_MIN} and {YEAR_MAX}")
if not 1 <= self.month <= 12:
raise ValidationError("month must be between 1 and 12")
@classmethod
def from_str(cls, text: str) -> Month:
"""Parse ``YYYY-MM``; raises ValidationError on bad input."""
try:
year_text, month_text = str(text).strip().split("-")
return cls(year=int(year_text), month=int(month_text))
except (TypeError, ValueError) as exc:
raise ValidationError(f"invalid month {text!r}, expected YYYY-MM") from exc
@classmethod
def now(cls) -> Month:
today = date.today()
return cls(year=today.year, month=today.month)
@property
def label(self) -> str:
return f"{self.year:04d}-{self.month:02d}"
@property
def first_day(self) -> date:
return date(self.year, self.month, 1)
@property
def last_day(self) -> date:
last = calendar.monthrange(self.year, self.month)[1]
return date(self.year, self.month, last)
@property
def range(self) -> tuple[str, str]:
"""Inclusive ISO date bounds (start, end) used by repository queries."""
return (self.first_day.isoformat(), self.last_day.isoformat())
@dataclass(frozen=True)
class MonthSummary:
"""Income, expenses, and net for one month (report read model)."""
month: Month
income: Money
expenses: Money
@property
def net(self) -> Money:
return self.income - self.expenses
@dataclass(frozen=True)
class CategoryTotal:
"""Total movement in one category over a period (report read model)."""
category: str
kind: str
total: Money
@dataclass(frozen=True)
class BudgetLine:
"""Budget vs. actual spending for one category in one month (R11)."""
category: str
budget: Money
spent: Money
@property
def remaining(self) -> Money:
return self.budget - self.spent
@property
def is_over(self) -> bool:
return self.spent > self.budget

101
ledgerly/money.py Normal file
View file

@ -0,0 +1,101 @@
"""Money value object.
All money in Ledgerly is stored and computed as *integer cents*. ``Money`` is
an immutable, total-ordered value object. Domain services enforce positivity
where it is required (transactions, budgets); reports may rely on negative
values for nets and remainders.
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Union
__all__ = ["InvalidMoney", "Money"]
class InvalidMoney(ValueError):
"""Raised when text cannot be parsed as a monetary amount (R3)."""
@dataclass(frozen=True)
class Money:
"""An immutable amount of money expressed in integer cents (signed)."""
cents: int
def __post_init__(self) -> None:
if isinstance(self.cents, bool) or not isinstance(self.cents, int):
raise TypeError(f"cents must be an int, got {type(self.cents).__name__}")
# -- construction ------------------------------------------------------
@classmethod
def zero(cls) -> Money:
return cls(cents=0)
@classmethod
def parse(cls, text: Union[str, int, float, Decimal]) -> Money:
"""Parse decimal text/number into Money (R3).
Accepts at most 2 decimal places; any further precision is rounded
half-up to cents. Raises :class:`InvalidMoney` for unparseable input.
"""
try:
value = Decimal(str(text).strip())
except (InvalidOperation, ValueError) as exc:
raise InvalidMoney(f"not a valid amount: {text!r}") from exc
if not value.is_finite():
raise InvalidMoney(f"not a valid amount: {text!r}")
cents = int(value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) * 100)
return cls(cents=cents)
# -- predicates --------------------------------------------------------
@property
def is_zero(self) -> bool:
return self.cents == 0
@property
def is_positive(self) -> bool:
return self.cents > 0
# -- arithmetic --------------------------------------------------------
def __add__(self, other: Money) -> Money:
if not isinstance(other, Money):
return NotImplemented
return Money(cents=self.cents + other.cents)
def __sub__(self, other: Money) -> Money:
if not isinstance(other, Money):
return NotImplemented
return Money(cents=self.cents - other.cents)
# -- ordering ----------------------------------------------------------
def __eq__(self, other: object) -> bool:
return isinstance(other, Money) and self.cents == other.cents
def __lt__(self, other: Money) -> bool:
return self.cents < other.cents
def __le__(self, other: Money) -> bool:
return self.cents <= other.cents
def __hash__(self) -> int:
return hash(("Money", self.cents))
# -- presentation ------------------------------------------------------
def __str__(self) -> str:
sign = "-" if self.cents < 0 else ""
return f"{sign}${abs(self.cents) / 100:,.2f}"
def __format__(self, spec: str) -> str:
"""Support alignment specs such as ``f"{money:<12}"``."""
return str.__format__(str(self), spec)
def __repr__(self) -> str:
return f"Money({self.cents})"

222
ledgerly/repository.py Normal file
View file

@ -0,0 +1,222 @@
"""Persistence layer: the ``Repository`` protocol and its SQLite implementation.
This is the only module that speaks SQL. Everything above it talks to the
``Repository`` protocol (dependency inversion), so services can be reasoned
about — and tested — without knowing SQLite exists.
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import Protocol, runtime_checkable
from .db import connect, seed_default_categories
from .errors import ConflictError
__all__ = ["Repository", "SqliteRepository"]
@runtime_checkable
class Repository(Protocol):
"""Storage contract used by the domain services (DIP)."""
# categories
def create_category(self, name: str, kind: str) -> None: ...
def find_category(self, name: str) -> tuple[int, str, str] | None: # pragma: no cover
"""Return ``(id, stored_name, kind)`` or None (case-insensitive)."""
def list_categories(self) -> list[tuple[str, str]]: ...
def category_in_use(self, category_id: int) -> bool: ...
def delete_category(self, category_id: int) -> None: ...
# transactions
def insert_transaction(
self, amount_cents: int, category_id: int, day: str, note: str | None
) -> int: ...
def list_transactions(
self, start: str | None, end: str | None, category_id: int | None
) -> list[sqlite3.Row]: ...
def transaction_exists(self, tx_id: int) -> bool: ...
def delete_transaction(self, tx_id: int) -> None: ...
# budgets
def set_budget(self, category_id: int, amount_cents: int) -> None: ...
def get_budget(self, category_id: int) -> int | None: ...
def list_budgets(self) -> list[sqlite3.Row]: ...
def delete_budget(self, category_id: int) -> bool: ...
# reporting
def income_expense_totals(self, start: str, end: str) -> tuple[int, int]: ...
def category_totals(self, start: str, end: str) -> list[sqlite3.Row]: ...
def close(self) -> None: ...
class SqliteRepository:
"""SQLite-backed repository; owns one connection for its lifetime."""
def __init__(self, db_path: str | Path) -> None:
self._conn = connect(db_path)
seed_default_categories(self._conn)
def close(self) -> None:
self._conn.close()
def __enter__(self) -> SqliteRepository:
return self
def __exit__(self, *exc_info: object) -> None:
self.close()
# -- categories --------------------------------------------------------
def create_category(self, name: str, kind: str) -> None:
try:
self._conn.execute(
"INSERT INTO categories (name, kind) VALUES (?, ?)", (name, kind)
)
except sqlite3.IntegrityError as exc:
raise ConflictError(f"category {name!r} already exists") from exc
self._conn.commit()
def find_category(self, name: str) -> tuple[int, str, str] | None:
row = self._conn.execute(
"SELECT id, name, kind FROM categories "
"WHERE name = ? COLLATE NOCASE LIMIT 1",
(name,),
).fetchone()
if row is None:
return None
return (row["id"], row["name"], row["kind"])
def list_categories(self) -> list[tuple[str, str]]:
rows = self._conn.execute(
"SELECT name, kind FROM categories ORDER BY kind DESC, name"
).fetchall()
return [(row["name"], row["kind"]) for row in rows]
def category_in_use(self, category_id: int) -> bool:
row = self._conn.execute(
"SELECT (SELECT COUNT(*) FROM transactions WHERE category_id = ?) "
" + (SELECT COUNT(*) FROM budgets WHERE category_id = ?) AS used",
(category_id, category_id),
).fetchone()
return bool(row["used"])
def delete_category(self, category_id: int) -> None:
self._conn.execute("DELETE FROM categories WHERE id = ?", (category_id,))
self._conn.commit()
# -- transactions ------------------------------------------------------
def insert_transaction(
self, amount_cents: int, category_id: int, day: str, note: str | None
) -> int:
cur = self._conn.execute(
"INSERT INTO transactions (amount_cents, category_id, date, note) "
"VALUES (?, ?, ?, ?)",
(amount_cents, category_id, day, note),
)
self._conn.commit()
return int(cur.lastrowid)
def list_transactions(
self, start: str | None, end: str | None, category_id: int | None
) -> list[sqlite3.Row]:
query = (
"SELECT t.id, t.amount_cents, t.date, t.note, "
" c.name AS category, c.kind "
"FROM transactions t JOIN categories c ON c.id = t.category_id"
)
clauses: list[str] = []
params: list[object] = []
if start is not None:
clauses.append("t.date >= ?")
params.append(start)
if end is not None:
clauses.append("t.date <= ?")
params.append(end)
if category_id is not None:
clauses.append("t.category_id = ?")
params.append(category_id)
if clauses:
query += " WHERE " + " AND ".join(clauses)
query += " ORDER BY t.date DESC, t.id DESC"
return self._conn.execute(query, params).fetchall()
def transaction_exists(self, tx_id: int) -> bool:
row = self._conn.execute(
"SELECT 1 FROM transactions WHERE id = ?", (tx_id,)
).fetchone()
return row is not None
def delete_transaction(self, tx_id: int) -> None:
self._conn.execute("DELETE FROM transactions WHERE id = ?", (tx_id,))
self._conn.commit()
# -- budgets -----------------------------------------------------------
def set_budget(self, category_id: int, amount_cents: int) -> None:
self._conn.execute(
"INSERT INTO budgets (category_id, amount_cents) VALUES (?, ?) "
"ON CONFLICT (category_id) DO UPDATE "
"SET amount_cents = excluded.amount_cents",
(category_id, amount_cents),
)
self._conn.commit()
def get_budget(self, category_id: int) -> int | None:
row = self._conn.execute(
"SELECT amount_cents FROM budgets WHERE category_id = ?", (category_id,)
).fetchone()
return None if row is None else int(row["amount_cents"])
def list_budgets(self) -> list[sqlite3.Row]:
return self._conn.execute(
"SELECT c.name AS category, c.kind AS kind, b.amount_cents "
"FROM budgets b JOIN categories c ON c.id = b.category_id "
"ORDER BY c.name"
).fetchall()
def delete_budget(self, category_id: int) -> bool:
cur = self._conn.execute(
"DELETE FROM budgets WHERE category_id = ?", (category_id,)
)
self._conn.commit()
return cur.rowcount > 0
# -- reporting ---------------------------------------------------------
def income_expense_totals(self, start: str, end: str) -> tuple[int, int]:
row = self._conn.execute(
"SELECT COALESCE(SUM(CASE WHEN c.kind = 'income' "
" THEN t.amount_cents END), 0) AS income, "
" COALESCE(SUM(CASE WHEN c.kind = 'expense' "
" THEN t.amount_cents END), 0) AS expenses "
"FROM transactions t JOIN categories c ON c.id = t.category_id "
"WHERE t.date >= ? AND t.date <= ?",
(start, end),
).fetchone()
return (int(row["income"]), int(row["expenses"]))
def category_totals(self, start: str, end: str) -> list[sqlite3.Row]:
return self._conn.execute(
"SELECT c.name AS category, c.kind AS kind, "
" SUM(t.amount_cents) AS total_cents "
"FROM transactions t JOIN categories c ON c.id = t.category_id "
"WHERE t.date >= ? AND t.date <= ? "
"GROUP BY c.id "
"ORDER BY c.kind DESC, total_cents DESC, c.name",
(start, end),
).fetchall()

View file

@ -0,0 +1,7 @@
"""Domain services: the application's command and query surface."""
from .budgets import BudgetService
from .ledger import LedgerService
from .reports import ReportingService
__all__ = ["BudgetService", "LedgerService", "ReportingService"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,56 @@
"""Commands and queries for monthly budgets (R2, R7)."""
from __future__ import annotations
from ..errors import NotFoundError, ValidationError
from ..money import Money
from ..models import INCOME, Budget, clean_name
from ..repository import Repository
__all__ = ["BudgetService"]
class BudgetService:
"""Owns budget rules: positivity, expense-only, replace/delete semantics."""
def __init__(self, repo: Repository) -> None:
self._repo = repo
# -- commands ----------------------------------------------------------
def set_budget(self, category: str, amount: Money) -> Budget:
clean = clean_name(category, "category")
if not amount.is_positive:
raise ValidationError("budget must be greater than zero")
ref = self._repo.find_category(clean)
if ref is None:
raise NotFoundError(f"unknown category {clean!r}")
category_id, stored_name, kind = ref
if kind == INCOME:
raise ValidationError(
f"budgets apply to expense categories only "
f"({stored_name!r} is income)"
)
self._repo.set_budget(category_id, amount.cents)
return Budget(category=stored_name, amount=amount)
def delete_budget(self, category: str) -> Budget:
"""Remove the budget for *category*; returns the deleted budget."""
clean = clean_name(category, "category")
ref = self._repo.find_category(clean)
if ref is None:
raise NotFoundError(f"unknown category {clean!r}")
category_id, stored_name, _kind = ref
cents = self._repo.get_budget(category_id)
if cents is None:
raise NotFoundError(f"no budget set for {stored_name!r}")
self._repo.delete_budget(category_id)
return Budget(category=stored_name, amount=Money(cents=cents))
# -- queries -----------------------------------------------------------
def list_budgets(self) -> list[Budget]:
return [
Budget(category=row["category"], amount=Money(cents=row["amount_cents"]))
for row in self._repo.list_budgets()
]

102
ledgerly/services/ledger.py Normal file
View file

@ -0,0 +1,102 @@
"""Commands and queries for categories and transactions.
CQRS: the ``add_*``/``delete_*`` methods are commands (state changes);
``list_*`` methods are queries (read-only). Neither side leaks the other.
"""
from __future__ import annotations
from datetime import date
from ..errors import ConflictError, NotFoundError, ValidationError
from ..money import Money
from ..models import Category, Transaction, clean_name, clean_note, validate_kind
from ..repository import Repository
__all__ = ["LedgerService"]
class LedgerService:
"""Owns category and transaction rules (R5-R10, R12)."""
def __init__(self, repo: Repository) -> None:
self._repo = repo
# -- commands ----------------------------------------------------------
def add_category(self, name: str, kind: str) -> Category:
clean = clean_name(name, "category")
validate_kind(kind)
if self._repo.find_category(clean) is not None:
raise ConflictError(f"category {clean!r} already exists")
self._repo.create_category(clean, kind)
return Category(name=clean, kind=kind)
def delete_category(self, name: str) -> None:
clean = clean_name(name, "category")
ref = self._repo.find_category(clean)
if ref is None:
raise NotFoundError(f"unknown category {clean!r}")
category_id, stored_name, _kind = ref
if self._repo.category_in_use(category_id):
raise ConflictError(
f"category {stored_name!r} has transactions or a budget"
)
self._repo.delete_category(category_id)
def add_transaction(
self, amount: Money, category: str, day: date, note: str | None = None
) -> Transaction:
if not amount.is_positive:
raise ValidationError("amount must be greater than zero")
ref = self._repo.find_category(category)
if ref is None:
raise NotFoundError(f"unknown category {category!r}")
category_id, stored_name, kind = ref
cleaned_note = clean_note(note)
tx_id = self._repo.insert_transaction(
amount.cents, category_id, day.isoformat(), cleaned_note
)
return Transaction(
id=tx_id,
amount=amount,
category=stored_name,
kind=kind,
date=day,
note=cleaned_note,
)
def delete_transaction(self, tx_id: int) -> None:
if not self._repo.transaction_exists(tx_id):
raise NotFoundError(f"unknown transaction #{tx_id}")
self._repo.delete_transaction(tx_id)
# -- queries -----------------------------------------------------------
def list_categories(self) -> list[Category]:
return [Category(name=name, kind=kind) for name, kind in self._repo.list_categories()]
def list_transactions(
self,
start: str | None,
end: str | None,
category: str | None = None,
) -> list[Transaction]:
category_id = None
if category is not None:
ref = self._repo.find_category(category)
if ref is None:
raise NotFoundError(f"unknown category {category!r}")
category_id = ref[0]
rows = self._repo.list_transactions(start, end, category_id)
return [
Transaction(
id=row["id"],
amount=Money(cents=row["amount_cents"]),
category=row["category"],
kind=row["kind"],
date=date.fromisoformat(row["date"]),
note=row["note"],
)
for row in rows
]

View file

@ -0,0 +1,63 @@
"""Read-only reporting over transactions and budgets (R11).
Queries only: nothing in this module mutates state.
"""
from __future__ import annotations
from ..money import Money
from ..models import EXPENSE, BudgetLine, CategoryTotal, Month, MonthSummary
from ..repository import Repository
__all__ = ["ReportingService"]
class ReportingService:
"""Monthly summaries, category breakdowns, and budget status."""
def __init__(self, repo: Repository) -> None:
self._repo = repo
def monthly_summary(self, month: Month) -> MonthSummary:
start, end = month.range
income_cents, expense_cents = self._repo.income_expense_totals(start, end)
return MonthSummary(
month=month,
income=Money(cents=income_cents),
expenses=Money(cents=expense_cents),
)
def category_totals(self, month: Month) -> list[CategoryTotal]:
start, end = month.range
return [
CategoryTotal(
category=row["category"],
kind=row["kind"],
total=Money(cents=row["total_cents"]),
)
for row in self._repo.category_totals(start, end)
]
def budget_status(self, month: Month) -> list[BudgetLine]:
"""Budgeted lines first, then unbudgeted spending (R11)."""
start, end = month.range
spent = {
row["category"]: Money(cents=row["total_cents"])
for row in self._repo.category_totals(start, end)
if row["kind"] == EXPENSE
}
lines: list[BudgetLine] = []
for row in self._repo.list_budgets():
name = row["category"]
lines.append(
BudgetLine(
category=name,
budget=Money(cents=row["amount_cents"]),
spent=spent.get(name, Money.zero()),
)
)
budgeted = {line.category for line in lines}
for name, cost in sorted(spent.items()):
if name not in budgeted and cost.is_positive:
lines.append(BudgetLine(category=name, budget=Money.zero(), spent=cost))
return lines

20
pyproject.toml Normal file
View file

@ -0,0 +1,20 @@
[project]
name = "ledgerly"
version = "0.1.0"
description = "A tiny personal finance tracker: transactions, budgets, monthly reports. Stdlib + SQLite only."
requires-python = ">=3.10"
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=8.0", "coverage>=7.4"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.coverage.run]
source = ["ledgerly"]
omit = ["ledgerly/__main__.py"]
[tool.coverage.report]
fail_under = 95
show_missing = true

View file

@ -0,0 +1,61 @@
"""Tests for ledgerly.services.budgets (R2, R7)."""
import pytest
from ledgerly.errors import NotFoundError, ValidationError
from ledgerly.money import Money
from ledgerly.repository import SqliteRepository
from ledgerly.services import BudgetService
@pytest.fixture()
def service():
repo = SqliteRepository(":memory:")
yield BudgetService(repo)
repo.close()
def test_set_budget(service):
budget = service.set_budget("Rent", Money(150000))
assert budget.category == "Rent"
assert budget.amount == Money(150000)
assert service.list_budgets() == [budget]
def test_set_budget_replaces(service):
service.set_budget("Rent", Money(100))
service.set_budget("Rent", Money(200))
assert [b.amount for b in service.list_budgets()] == [Money(200)]
def test_set_budget_rejects_zero(service):
with pytest.raises(ValidationError, match="greater than zero"):
service.set_budget("Rent", Money(0))
def test_set_budget_unknown_category(service):
with pytest.raises(NotFoundError):
service.set_budget("Nope", Money(100))
def test_set_budget_rejects_income_category(service):
with pytest.raises(ValidationError, match="expense"):
service.set_budget("Salary", Money(100))
def test_delete_budget_missing_category(service):
with pytest.raises(NotFoundError):
service.delete_budget("Nope")
def test_delete_budget_not_set(service):
with pytest.raises(NotFoundError, match="no budget"):
service.delete_budget("Rent")
def test_delete_budget_ok(service):
service.set_budget("Rent", Money(100))
deleted = service.delete_budget("rent")
assert deleted.category == "Rent"
assert deleted.amount == Money(100)
assert service.list_budgets() == []

235
tests/test_cli.py Normal file
View file

@ -0,0 +1,235 @@
"""End-to-end CLI tests: main() with a temp database, captured stdout/stderr."""
import pytest
from ledgerly import __version__
from ledgerly.cli import build_parser, default_db_path, main
from ledgerly.models import DEFAULT_CATEGORIES
@pytest.fixture()
def db(tmp_path):
return str(tmp_path / "test.db")
def run(db, *argv):
return main(list(argv), db_path=db)
# -- basics --------------------------------------------------------------------
def test_version(db, capsys):
with pytest.raises(SystemExit) as exc:
main(["--version"], db_path=db)
assert exc.value.code == 0
assert __version__ in capsys.readouterr().out
def test_missing_command(db):
with pytest.raises(SystemExit) as exc:
main([], db_path=db)
assert exc.value.code == 2
def test_default_db_path_under_home():
path = default_db_path()
assert path.name == "ledgerly.db"
assert ".ledgerly" in path.parts
def test_parser_exposes_all_commands():
parser = build_parser()
assert parser.prog == "ledgerly"
# -- add / list / delete ---------------------------------------------------------
def test_add_and_list(db, capsys):
assert run(db, "add", "82.10", "--category", "Groceries",
"--date", "2026-10-02", "--note", "weekly") == 0
out = capsys.readouterr().out
assert "#1" in out and "$82.10" in out and "weekly" in out
assert run(db, "list", "--month", "2026-10") == 0
assert "Groceries" in capsys.readouterr().out
def test_add_default_date_and_month(db, capsys):
assert run(db, "add", "10.00", "--category", "Rent") == 0
assert run(db, "list") == 0 # current month by default
assert "Rent" in capsys.readouterr().out
def test_add_invalid_amount(db):
with pytest.raises(SystemExit) as exc:
run(db, "add", "abc", "--category", "Rent")
assert exc.value.code == 2
def test_add_invalid_date(db):
with pytest.raises(SystemExit) as exc:
run(db, "add", "1.00", "--category", "Rent", "--date", "nope")
assert exc.value.code == 2
def test_add_unknown_category_domain_error(db, capsys):
assert run(db, "add", "1.00", "--category", "Nope") == 1
assert "unknown category" in capsys.readouterr().err
def test_add_zero_amount(db, capsys):
assert run(db, "add", "0", "--category", "Rent") == 1
assert "greater than zero" in capsys.readouterr().err
def test_list_all(db, capsys):
run(db, "add", "5.00", "--category", "Rent", "--date", "2025-01-05")
run(db, "add", "6.00", "--category", "Rent", "--date", "2026-12-01")
assert run(db, "list", "--all") == 0
out = capsys.readouterr().out
assert "2025-01-05" in out and "2026-12-01" in out
def test_list_no_transactions(db, capsys):
assert run(db, "list", "--month", "2001-01") == 0
assert "no transactions" in capsys.readouterr().out
def test_list_by_category(db, capsys):
run(db, "add", "5.00", "--category", "Rent", "--date", "2026-10-05")
run(db, "add", "6.00", "--category", "Groceries", "--date", "2026-10-06")
capsys.readouterr() # discard add output; keep the list output clean
assert run(db, "list", "--month", "2026-10", "--category", "rent") == 0
out = capsys.readouterr().out
assert "Rent" in out and "Groceries" not in out
def test_delete(db, capsys):
assert run(db, "add", "5.00", "--category", "Rent", "--date", "2026-01-05") == 0
out = capsys.readouterr().out
tx_id = int(out.split("#", 1)[1].split(":", 1)[0].strip())
assert run(db, "delete", str(tx_id)) == 0
assert "deleted" in capsys.readouterr().out
def test_delete_unknown(db, capsys):
assert run(db, "delete", "999") == 1
assert "unknown transaction" in capsys.readouterr().err
# -- categories ------------------------------------------------------------------
def test_categories(db, capsys):
assert run(db, "categories") == 0
out = capsys.readouterr().out
assert "Salary" in out and "Rent" in out
def test_categories_empty(db, capsys):
for name, _kind in DEFAULT_CATEGORIES:
assert run(db, "category-del", name) == 0
assert run(db, "categories") == 0
assert "no categories" in capsys.readouterr().out
def test_category_add_and_del(db, capsys):
assert run(db, "category-add", "Pets", "--type", "expense") == 0
assert "Pets" in capsys.readouterr().out
assert run(db, "category-add", "pets", "--type", "income") == 1
assert "already exists" in capsys.readouterr().err
assert run(db, "category-del", "Pets") == 0
assert "deleted category" in capsys.readouterr().out
def test_category_add_bad_type(db):
with pytest.raises(SystemExit) as exc:
run(db, "category-add", "Pets", "--type", "asset")
assert exc.value.code == 2
def test_category_del_in_use(db, capsys):
run(db, "add", "5.00", "--category", "Rent", "--date", "2026-01-05")
assert run(db, "category-del", "Rent") == 1
assert "transactions or a budget" in capsys.readouterr().err
# -- budgets -----------------------------------------------------------------------
def test_budget_set_and_del(db, capsys):
assert run(db, "budget-set", "Rent", "1500") == 0
assert "$1,500.00" in capsys.readouterr().out
assert run(db, "budget-set", "rent", "2000") == 0 # replace
assert "$2,000.00" in capsys.readouterr().out
assert run(db, "budget-del", "Rent") == 0
assert "removed budget" in capsys.readouterr().out
def test_budget_set_income_category(db, capsys):
assert run(db, "budget-set", "Salary", "100") == 1
assert "expense" in capsys.readouterr().err
def test_budget_set_invalid_amount(db):
with pytest.raises(SystemExit) as exc:
run(db, "budget-set", "Rent", "not-money")
assert exc.value.code == 2
def test_budget_del_not_set(db, capsys):
assert run(db, "budget-del", "Rent") == 1
assert "no budget" in capsys.readouterr().err
def test_budget_show_with_overage(db, capsys):
run(db, "budget-set", "Rent", "1500")
run(db, "add", "1600.00", "--category", "Rent", "--date", "2026-10-05")
assert run(db, "budget-show", "--month", "2026-10") == 0
out = capsys.readouterr().out
assert "Rent" in out and "OVER" in out and "budgets for 2026-10" in out
def test_budget_show_flags_unbudgeted_spending(db, capsys):
run(db, "add", "40.00", "--category", "Groceries", "--date", "2026-10-02")
assert run(db, "budget-show", "--month", "2026-10") == 0
out = capsys.readouterr().out
assert "Groceries" in out and "OVER" in out
def test_budget_show_no_budgets_no_spending(db, capsys):
assert run(db, "budget-show", "--month", "2026-10") == 0
assert "no budgets set" in capsys.readouterr().out
# -- report ---------------------------------------------------------------------------
def test_report(db, capsys):
run(db, "add", "3000", "--category", "Salary", "--date", "2026-10-01")
run(db, "add", "1200", "--category", "Rent", "--date", "2026-10-02")
run(db, "add", "250", "--category", "Groceries", "--date", "2026-10-03")
assert run(db, "report", "--month", "2026-10") == 0
out = capsys.readouterr().out
assert "report for 2026-10" in out
assert "income" in out and "$3,000.00" in out
assert "expenses" in out and "$1,450.00" in out
assert "net" in out and "$1,550.00" in out
assert "Salary" in out and "Groceries" in out
def test_report_empty(db, capsys):
assert run(db, "report", "--month", "2001-01") == 0
assert "no transactions" in capsys.readouterr().out
def test_report_default_month(db, capsys):
assert run(db, "report") == 0
assert "report for" in capsys.readouterr().out
def test_invalid_month(db):
with pytest.raises(SystemExit) as exc:
run(db, "report", "--month", "2026-99")
assert exc.value.code == 2

View file

@ -0,0 +1,124 @@
"""Tests for ledgerly.services.ledger (R5-R10, R12)."""
from datetime import date
import pytest
from ledgerly.errors import ConflictError, NotFoundError, ValidationError
from ledgerly.models import EXPENSE, INCOME, Month
from ledgerly.money import Money
from ledgerly.repository import SqliteRepository
from ledgerly.services import LedgerService
@pytest.fixture()
def service():
repo = SqliteRepository(":memory:")
yield LedgerService(repo)
repo.close()
# -- commands ------------------------------------------------------------------
def test_add_category(service):
cat = service.add_category("Pets", EXPENSE)
assert cat.name == "Pets"
assert cat.kind == EXPENSE
assert "Pets" in [c.name for c in service.list_categories()]
def test_add_category_duplicate(service):
service.add_category("Pets", EXPENSE)
with pytest.raises(ConflictError):
service.add_category("pets", EXPENSE)
def test_add_category_invalid_name(service):
with pytest.raises(ValidationError):
service.add_category(" ", EXPENSE)
def test_add_category_invalid_kind(service):
with pytest.raises(ValidationError):
service.add_category("Pets", "asset")
def test_delete_category_missing(service):
with pytest.raises(NotFoundError):
service.delete_category("Nope")
def test_delete_category_in_use(service):
service.add_transaction(Money(100), "Rent", date(2026, 10, 1))
with pytest.raises(ConflictError, match="transactions or a budget"):
service.delete_category("Rent")
def test_delete_category_ok(service):
service.delete_category("Rent")
assert all(c.name != "Rent" for c in service.list_categories())
def test_add_transaction_ok(service):
tx = service.add_transaction(Money(8210), "Groceries", date(2026, 10, 2), " weekly ")
assert tx.id >= 1
assert tx.category == "Groceries"
assert tx.kind == EXPENSE
assert tx.note == "weekly"
def test_add_transaction_rejects_zero(service):
with pytest.raises(ValidationError, match="greater than zero"):
service.add_transaction(Money(0), "Rent", date(2026, 10, 1))
def test_add_transaction_unknown_category(service):
with pytest.raises(NotFoundError):
service.add_transaction(Money(10), "Nope", date(2026, 10, 1))
def test_add_transaction_long_note(service):
with pytest.raises(ValidationError, match="200"):
service.add_transaction(Money(10), "Rent", date(2026, 10, 1), "x" * 201)
def test_delete_transaction_missing(service):
with pytest.raises(NotFoundError):
service.delete_transaction(9999)
def test_delete_transaction_ok(service):
tx = service.add_transaction(Money(100), "Rent", date(2026, 10, 1))
service.delete_transaction(tx.id)
assert service.list_transactions(None, None) == []
# -- queries -------------------------------------------------------------------
def test_list_transactions_filter_by_month(service):
service.add_transaction(Money(100), "Rent", date(2026, 10, 1))
service.add_transaction(Money(200), "Rent", date(2026, 11, 1))
rows = service.list_transactions(*Month(2026, 10).range)
assert len(rows) == 1
assert rows[0].amount == Money(100)
def test_list_transactions_filter_by_category(service):
service.add_transaction(Money(100), "Rent", date(2026, 10, 1))
service.add_transaction(Money(200), "Groceries", date(2026, 10, 2))
rows = service.list_transactions(None, None, "rent")
assert len(rows) == 1
assert rows[0].category == "Rent"
assert rows[0].kind == EXPENSE
def test_list_transactions_unknown_category(service):
with pytest.raises(NotFoundError):
service.list_transactions(None, None, "Nope")
def test_income_transaction_allowed(service):
tx = service.add_transaction(Money(300000), "Salary", date(2026, 10, 1))
assert tx.kind == INCOME

120
tests/test_models.py Normal file
View file

@ -0,0 +1,120 @@
"""Tests for ledgerly.models (sanitizers, Month, read models)."""
from dataclasses import FrozenInstanceError
from datetime import date
import pytest
from ledgerly.errors import ValidationError
from ledgerly.models import (
BudgetLine,
Category,
Month,
MonthSummary,
clean_name,
clean_note,
parse_date,
validate_kind,
)
from ledgerly.money import Money
def test_clean_name_ok():
assert clean_name(" Rent ") == "Rent"
def test_clean_name_rejects_blank():
with pytest.raises(ValidationError):
clean_name(" ")
with pytest.raises(ValidationError):
clean_name(None)
def test_clean_name_rejects_long():
with pytest.raises(ValidationError, match="50"):
clean_name("x" * 51)
def test_clean_note():
assert clean_note(None) is None
assert clean_note(" ") is None
assert clean_note("a note") == "a note"
def test_clean_note_rejects_long():
with pytest.raises(ValidationError, match="200"):
clean_note("n" * 201)
def test_parse_date_ok():
assert parse_date("2026-10-26") == date(2026, 10, 26)
def test_parse_date_rejects_bad():
for bad in ("2026-13-01", "2026-02-30", "25-10-01", "not-a-date", 123):
with pytest.raises(ValidationError):
parse_date(bad)
def test_parse_date_rejects_out_of_range():
with pytest.raises(ValidationError):
parse_date("1999-12-31")
with pytest.raises(ValidationError):
parse_date("2101-01-01")
def test_validate_kind():
assert validate_kind("income") == "income"
with pytest.raises(ValidationError):
validate_kind("asset")
def test_month_from_str_ok():
month = Month.from_str("2026-10")
assert (month.year, month.month) == (2026, 10)
assert month.label == "2026-10"
assert month.first_day == date(2026, 10, 1)
assert month.last_day == date(2026, 10, 31)
def test_month_from_str_rejects_bad():
for bad in ("2026", "26-10", "2026-13", "2026-", "2026-1-3", "garbage", 2026):
with pytest.raises(ValidationError):
Month.from_str(bad)
def test_month_constructor_validation():
with pytest.raises(ValidationError):
Month(2026, 13)
with pytest.raises(ValidationError):
Month(1999, 5)
def test_month_range_leap_year():
month = Month(year=2024, month=2)
assert month.range == ("2024-02-01", "2024-02-29")
def test_month_now_is_sane():
month = Month.now()
assert 1 <= month.month <= 12
def test_month_summary_net():
summary = MonthSummary(month=Month.now(), income=Money(3000), expenses=Money(3100))
assert summary.net == Money(-100)
def test_budget_line_over_and_under():
over = BudgetLine(category="Rent", budget=Money(1000), spent=Money(1200))
assert over.is_over
assert over.remaining == Money(-200)
under = BudgetLine(category="Rent", budget=Money(1000), spent=Money(800))
assert not under.is_over
assert under.remaining == Money(200)
def test_entities_are_frozen():
cat = Category(name="Rent", kind="expense")
with pytest.raises(FrozenInstanceError):
cat.name = "Other"

98
tests/test_money.py Normal file
View file

@ -0,0 +1,98 @@
"""Tests for ledgerly.money (R3, Money value semantics)."""
import pytest
from ledgerly.money import InvalidMoney, Money
def test_parse_integer_string():
assert Money.parse("42") == Money(4200)
def test_parse_two_decimals():
assert Money.parse("12.34") == Money(1234)
def test_parse_rounds_half_up():
assert Money.parse("12.345") == Money(1235)
assert Money.parse("12.344") == Money(1234)
def test_parse_negative():
assert Money.parse("-1.50") == Money(-150)
def test_parse_accepts_numbers():
assert Money.parse(3) == Money(300)
assert Money.parse(2.5) == Money(250)
def test_parse_rejects_garbage():
for bad in ("abc", "", None):
with pytest.raises(InvalidMoney):
Money.parse(bad)
def test_parse_rejects_non_finite():
for bad in ("inf", "-inf", "nan"):
with pytest.raises(InvalidMoney):
Money.parse(bad)
def test_cents_must_be_int():
for bad in ("5", 1.5, True):
with pytest.raises(TypeError):
Money(bad)
def test_predicates():
assert Money(0).is_zero
assert not Money(1).is_zero
assert Money(1).is_positive
assert not Money(0).is_positive
assert not Money(-1).is_positive
def test_zero_classmethod():
assert Money.zero() == Money(0)
def test_arithmetic():
a, b = Money(100), Money(25)
assert a + b == Money(125)
assert a - b == Money(75)
assert Money(10) - Money(20) == Money(-10)
def test_arithmetic_type_errors():
with pytest.raises(TypeError):
Money(1) + 1
with pytest.raises(TypeError):
Money(1) - "1"
def test_ordering_and_hash():
assert Money(1) < Money(2)
assert Money(2) <= Money(2)
assert Money(2) == Money(2)
assert hash(Money(2)) == hash(Money(2))
def test_not_equal_to_other_types():
assert Money(1) != 1
assert Money(1) != "1"
def test_str_formatting():
assert str(Money(0)) == "$0.00"
assert str(Money(123456)) == "$1,234.56"
assert str(Money(-100)) == "-$1.00"
def test_format_supports_alignment():
assert format(Money(150000), "<12") == "$1,500.00 "
assert format(Money(150000), "") == "$1,500.00"
def test_repr():
assert repr(Money(42)) == "Money(42)"

View file

@ -0,0 +1,76 @@
"""Tests for ledgerly.services.reports (R11)."""
from datetime import date
import pytest
from ledgerly.models import Month
from ledgerly.money import Money
from ledgerly.repository import SqliteRepository
from ledgerly.services import BudgetService, LedgerService, ReportingService
@pytest.fixture()
def services():
repo = SqliteRepository(":memory:")
yield LedgerService(repo), BudgetService(repo), ReportingService(repo)
repo.close()
def test_monthly_summary_empty(services):
_ledger, _budgets, reports = services
summary = reports.monthly_summary(Month(2026, 10))
assert summary.income == Money(0)
assert summary.expenses == Money(0)
assert summary.net == Money(0)
def test_monthly_summary_with_data(services):
ledger, _budgets, reports = services
ledger.add_transaction(Money(300000), "Salary", date(2026, 10, 1))
ledger.add_transaction(Money(50000), "Groceries", date(2026, 10, 5))
ledger.add_transaction(Money(1), "Rent", date(2026, 9, 5)) # out of month
summary = reports.monthly_summary(Month(2026, 10))
assert summary.income == Money(300000)
assert summary.expenses == Money(50000)
assert summary.net == Money(250000)
def test_category_totals_ordering(services):
ledger, _budgets, reports = services
ledger.add_transaction(Money(300000), "Salary", date(2026, 10, 1))
ledger.add_transaction(Money(50000), "Groceries", date(2026, 10, 2))
ledger.add_transaction(Money(90000), "Rent", date(2026, 10, 3))
totals = reports.category_totals(Month(2026, 10))
assert [(t.category, t.total) for t in totals] == [
("Salary", Money(300000)),
("Rent", Money(90000)),
("Groceries", Money(50000)),
]
def test_budget_status_budgeted_and_unbudgeted(services):
ledger, budgets, reports = services
budgets.set_budget("Rent", Money(150000))
ledger.add_transaction(Money(160000), "Rent", date(2026, 10, 1)) # over
ledger.add_transaction(Money(40000), "Groceries", date(2026, 10, 2)) # unbudgeted
lines = reports.budget_status(Month(2026, 10))
by_name = {line.category: line for line in lines}
assert by_name["Rent"].is_over
assert by_name["Rent"].remaining == Money(-10000)
assert by_name["Groceries"].budget == Money(0)
assert by_name["Groceries"].is_over
def test_budget_status_within_budget(services):
ledger, budgets, reports = services
budgets.set_budget("Rent", Money(150000))
ledger.add_transaction(Money(100000), "Rent", date(2026, 10, 1))
line = reports.budget_status(Month(2026, 10))[0]
assert not line.is_over
assert line.remaining == Money(50000)
def test_budget_status_empty(services):
_ledger, _budgets, reports = services
assert reports.budget_status(Month(2026, 10)) == []

188
tests/test_repository.py Normal file
View file

@ -0,0 +1,188 @@
"""Tests for ledgerly.repository (schema, seeds, CRUD, reporting queries)."""
import sqlite3
import pytest
from ledgerly.errors import ConflictError
from ledgerly.models import DEFAULT_CATEGORIES, EXPENSE, INCOME
from ledgerly.repository import Repository, SqliteRepository
@pytest.fixture()
def repo():
r = SqliteRepository(":memory:")
yield r
r.close()
def test_implements_protocol():
r = SqliteRepository(":memory:")
assert isinstance(r, Repository)
r.close()
def test_seeds_default_categories(repo):
cats = repo.list_categories()
names = {name for name, _ in cats}
assert "Salary" in names
assert "Rent" in names
assert len(cats) == len(DEFAULT_CATEGORIES)
def test_seed_idempotent(tmp_path):
path = tmp_path / "sub" / "t.db"
first = SqliteRepository(path)
count = len(first.list_categories())
first.close()
second = SqliteRepository(path)
assert len(second.list_categories()) == count
second.close()
def test_deleted_categories_do_not_resurrect(tmp_path):
path = tmp_path / "t.db"
first = SqliteRepository(path)
for name, _kind in first.list_categories():
first.delete_category(first.find_category(name)[0])
first.close()
second = SqliteRepository(path)
assert second.list_categories() == []
second.close()
def test_create_category_duplicate_raises(repo):
with pytest.raises(ConflictError):
repo.create_category("Groceries", EXPENSE)
def test_find_category_case_insensitive(repo):
_id, name, kind = repo.find_category("grocErIes")
assert name == "Groceries"
assert kind == EXPENSE
def test_find_category_missing(repo):
assert repo.find_category("Nope") is None
def test_list_categories_income_first(repo):
cats = repo.list_categories()
kinds = [kind for _name, kind in cats]
assert kinds.index(INCOME) < kinds.index(EXPENSE)
def test_category_in_use_transitions(repo):
rent_id = repo.find_category("Rent")[0]
assert not repo.category_in_use(rent_id)
tx_id = repo.insert_transaction(100, rent_id, "2026-10-01", None)
assert repo.category_in_use(rent_id)
repo.delete_transaction(tx_id)
assert not repo.category_in_use(rent_id)
def test_category_in_use_via_budget(repo):
rent_id = repo.find_category("Rent")[0]
repo.set_budget(rent_id, 1000)
assert repo.category_in_use(rent_id)
def test_delete_category(repo):
cat_id = repo.find_category("Rent")[0]
repo.delete_category(cat_id)
assert repo.find_category("Rent") is None
def test_insert_and_list_transactions(repo):
cat_id = repo.find_category("Groceries")[0]
repo.insert_transaction(8210, cat_id, "2026-10-02", "weekly shop")
repo.insert_transaction(4500, cat_id, "2026-10-20", None)
rows = repo.list_transactions(None, None, None)
assert rows[0]["date"] == "2026-10-20" # newest first
assert rows[0]["category"] == "Groceries"
assert rows[0]["note"] is None
def test_list_transactions_filters(repo):
grocery_id = repo.find_category("Groceries")[0]
salary_id = repo.find_category("Salary")[0]
repo.insert_transaction(8210, grocery_id, "2026-10-02", None)
repo.insert_transaction(4500, salary_id, "2026-11-01", None)
assert len(repo.list_transactions("2026-10-01", "2026-10-31", None)) == 1
assert len(repo.list_transactions(None, None, grocery_id)) == 1
def test_transaction_exists_and_delete(repo):
cat_id = repo.find_category("Rent")[0]
tx_id = repo.insert_transaction(150000, cat_id, "2026-10-01", None)
assert repo.transaction_exists(tx_id)
repo.delete_transaction(tx_id)
assert not repo.transaction_exists(tx_id)
def test_insert_enforces_positive_amount(repo):
cat_id = repo.find_category("Rent")[0]
with pytest.raises(sqlite3.IntegrityError):
repo.insert_transaction(0, cat_id, "2026-10-01", None)
def test_insert_enforces_foreign_key(repo):
with pytest.raises(sqlite3.IntegrityError):
repo.insert_transaction(100, 99999, "2026-10-01", None)
def test_set_budget_upsert(repo):
cat_id = repo.find_category("Rent")[0]
repo.set_budget(cat_id, 100000)
assert repo.get_budget(cat_id) == 100000
repo.set_budget(cat_id, 120000)
assert repo.get_budget(cat_id) == 120000
assert len(repo.list_budgets()) == 1
def test_delete_budget(repo):
cat_id = repo.find_category("Rent")[0]
assert not repo.delete_budget(cat_id)
repo.set_budget(cat_id, 1000)
assert repo.delete_budget(cat_id)
assert repo.get_budget(cat_id) is None
def test_list_budgets_shape(repo):
repo.set_budget(repo.find_category("Rent")[0], 12345)
row = repo.list_budgets()[0]
assert row["category"] == "Rent"
assert row["kind"] == EXPENSE
assert row["amount_cents"] == 12345
def test_income_expense_totals_empty(repo):
assert repo.income_expense_totals("2026-10-01", "2026-10-31") == (0, 0)
def test_income_expense_totals(repo):
salary_id = repo.find_category("Salary")[0]
grocery_id = repo.find_category("Groceries")[0]
repo.insert_transaction(300000, salary_id, "2026-10-01", None)
repo.insert_transaction(50000, grocery_id, "2026-10-05", None)
repo.insert_transaction(7777, grocery_id, "2026-09-05", None) # out of range
assert repo.income_expense_totals("2026-10-01", "2026-10-31") == (300000, 50000)
def test_category_totals_ordering(repo):
salary_id = repo.find_category("Salary")[0]
grocery_id = repo.find_category("Groceries")[0]
rent_id = repo.find_category("Rent")[0]
repo.insert_transaction(300000, salary_id, "2026-10-01", None)
repo.insert_transaction(50000, grocery_id, "2026-10-05", None)
repo.insert_transaction(99999, rent_id, "2026-10-03", None)
rows = repo.category_totals("2026-10-01", "2026-10-31")
assert [r["category"] for r in rows] == ["Salary", "Rent", "Groceries"]
def test_context_manager_closes(tmp_path):
path = tmp_path / "t.db"
with SqliteRepository(path) as repo:
assert repo is not None
with pytest.raises(sqlite3.ProgrammingError):
repo.list_categories()