Compare commits

..

No commits in common. "f6e6542b3d650d594bdf2cb8b4ecb1653f2d252b" and "9e94f740de6da3b6cbad090f9300e70478fddebe" have entirely different histories.

37 changed files with 1 additions and 2303 deletions

View file

@ -1,20 +0,0 @@
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

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/

View file

@ -1,47 +0,0 @@
# 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
View file

@ -1,188 +0,0 @@
# 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).

View file

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

View file

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

View file

@ -1,252 +0,0 @@
"""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

View file

@ -1,74 +0,0 @@
"""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()

View file

@ -1,24 +0,0 @@
"""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)."""

View file

@ -1,211 +0,0 @@
"""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

View file

@ -1,101 +0,0 @@
"""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})"

View file

@ -1,222 +0,0 @@
"""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

@ -1,7 +0,0 @@
"""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"]

View file

@ -1,56 +0,0 @@
"""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()
]

View file

@ -1,102 +0,0 @@
"""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

@ -1,63 +0,0 @@
"""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

View file

@ -1,20 +0,0 @@
[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

@ -1,61 +0,0 @@
"""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() == []

View file

@ -1,235 +0,0 @@
"""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

@ -1,124 +0,0 @@
"""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

View file

@ -1,120 +0,0 @@
"""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"

View file

@ -1,98 +0,0 @@
"""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

@ -1,76 +0,0 @@
"""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)) == []

View file

@ -1,188 +0,0 @@
"""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()