commit 5b41e80ae1ea92b52b6a92873facf5b47229ad00 Author: Funky Waddle Date: Tue Oct 27 11:04:50 2026 -0500 Initial commit diff --git a/.ai/guidelines/guidelines.md b/.ai/guidelines/guidelines.md new file mode 100644 index 0000000..76a0353 --- /dev/null +++ b/.ai/guidelines/guidelines.md @@ -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. \ No newline at end of file diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..5ae6da3 Binary files /dev/null and b/.coverage differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..80b1ca5 --- /dev/null +++ b/README.md @@ -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/) +``` diff --git a/SPECS.md b/SPECS.md new file mode 100644 index 0000000..3a274f3 --- /dev/null +++ b/SPECS.md @@ -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). diff --git a/ledgerly/__init__.py b/ledgerly/__init__.py new file mode 100644 index 0000000..63f6cc1 --- /dev/null +++ b/ledgerly/__init__.py @@ -0,0 +1,5 @@ +"""Ledgerly — a tiny personal finance tracker (transactions, budgets, reports).""" + +__version__ = "0.1.0" + +__all__ = ["__version__"] diff --git a/ledgerly/__main__.py b/ledgerly/__main__.py new file mode 100644 index 0000000..f35c56d --- /dev/null +++ b/ledgerly/__main__.py @@ -0,0 +1,8 @@ +"""Entry point for ``python -m ledgerly``.""" + +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ledgerly/__pycache__/__init__.cpython-313.pyc b/ledgerly/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..c1ceb20 Binary files /dev/null and b/ledgerly/__pycache__/__init__.cpython-313.pyc differ diff --git a/ledgerly/__pycache__/__main__.cpython-313.pyc b/ledgerly/__pycache__/__main__.cpython-313.pyc new file mode 100644 index 0000000..43b15fc Binary files /dev/null and b/ledgerly/__pycache__/__main__.cpython-313.pyc differ diff --git a/ledgerly/__pycache__/cli.cpython-313.pyc b/ledgerly/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000..2a02320 Binary files /dev/null and b/ledgerly/__pycache__/cli.cpython-313.pyc differ diff --git a/ledgerly/__pycache__/db.cpython-313.pyc b/ledgerly/__pycache__/db.cpython-313.pyc new file mode 100644 index 0000000..715bafd Binary files /dev/null and b/ledgerly/__pycache__/db.cpython-313.pyc differ diff --git a/ledgerly/__pycache__/errors.cpython-313.pyc b/ledgerly/__pycache__/errors.cpython-313.pyc new file mode 100644 index 0000000..cee1732 Binary files /dev/null and b/ledgerly/__pycache__/errors.cpython-313.pyc differ diff --git a/ledgerly/__pycache__/models.cpython-313.pyc b/ledgerly/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..d5a0ab2 Binary files /dev/null and b/ledgerly/__pycache__/models.cpython-313.pyc differ diff --git a/ledgerly/__pycache__/money.cpython-313.pyc b/ledgerly/__pycache__/money.cpython-313.pyc new file mode 100644 index 0000000..8adeea7 Binary files /dev/null and b/ledgerly/__pycache__/money.cpython-313.pyc differ diff --git a/ledgerly/__pycache__/repository.cpython-313.pyc b/ledgerly/__pycache__/repository.cpython-313.pyc new file mode 100644 index 0000000..b812737 Binary files /dev/null and b/ledgerly/__pycache__/repository.cpython-313.pyc differ diff --git a/ledgerly/cli.py b/ledgerly/cli.py new file mode 100644 index 0000000..7ef0b94 --- /dev/null +++ b/ledgerly/cli.py @@ -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 diff --git a/ledgerly/db.py b/ledgerly/db.py new file mode 100644 index 0000000..4c44023 --- /dev/null +++ b/ledgerly/db.py @@ -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() diff --git a/ledgerly/errors.py b/ledgerly/errors.py new file mode 100644 index 0000000..b47bea4 --- /dev/null +++ b/ledgerly/errors.py @@ -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).""" diff --git a/ledgerly/models.py b/ledgerly/models.py new file mode 100644 index 0000000..eb44fe2 --- /dev/null +++ b/ledgerly/models.py @@ -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 diff --git a/ledgerly/money.py b/ledgerly/money.py new file mode 100644 index 0000000..c3342ac --- /dev/null +++ b/ledgerly/money.py @@ -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})" diff --git a/ledgerly/repository.py b/ledgerly/repository.py new file mode 100644 index 0000000..a513a97 --- /dev/null +++ b/ledgerly/repository.py @@ -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() diff --git a/ledgerly/services/__init__.py b/ledgerly/services/__init__.py new file mode 100644 index 0000000..a62a360 --- /dev/null +++ b/ledgerly/services/__init__.py @@ -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"] diff --git a/ledgerly/services/__pycache__/__init__.cpython-313.pyc b/ledgerly/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..a09e76d Binary files /dev/null and b/ledgerly/services/__pycache__/__init__.cpython-313.pyc differ diff --git a/ledgerly/services/__pycache__/budgets.cpython-313.pyc b/ledgerly/services/__pycache__/budgets.cpython-313.pyc new file mode 100644 index 0000000..294e9c8 Binary files /dev/null and b/ledgerly/services/__pycache__/budgets.cpython-313.pyc differ diff --git a/ledgerly/services/__pycache__/ledger.cpython-313.pyc b/ledgerly/services/__pycache__/ledger.cpython-313.pyc new file mode 100644 index 0000000..655ed74 Binary files /dev/null and b/ledgerly/services/__pycache__/ledger.cpython-313.pyc differ diff --git a/ledgerly/services/__pycache__/reports.cpython-313.pyc b/ledgerly/services/__pycache__/reports.cpython-313.pyc new file mode 100644 index 0000000..5af2bfc Binary files /dev/null and b/ledgerly/services/__pycache__/reports.cpython-313.pyc differ diff --git a/ledgerly/services/budgets.py b/ledgerly/services/budgets.py new file mode 100644 index 0000000..55aa384 --- /dev/null +++ b/ledgerly/services/budgets.py @@ -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() + ] diff --git a/ledgerly/services/ledger.py b/ledgerly/services/ledger.py new file mode 100644 index 0000000..a492634 --- /dev/null +++ b/ledgerly/services/ledger.py @@ -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 + ] diff --git a/ledgerly/services/reports.py b/ledgerly/services/reports.py new file mode 100644 index 0000000..14ef115 --- /dev/null +++ b/ledgerly/services/reports.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c279fbb --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/tests/test_budgets_service.py b/tests/test_budgets_service.py new file mode 100644 index 0000000..b73a3d6 --- /dev/null +++ b/tests/test_budgets_service.py @@ -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() == [] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..73313a5 --- /dev/null +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_ledger_service.py b/tests/test_ledger_service.py new file mode 100644 index 0000000..3a9557e --- /dev/null +++ b/tests/test_ledger_service.py @@ -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 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..ee10542 --- /dev/null +++ b/tests/test_models.py @@ -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" diff --git a/tests/test_money.py b/tests/test_money.py new file mode 100644 index 0000000..8e2f27f --- /dev/null +++ b/tests/test_money.py @@ -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)" diff --git a/tests/test_reports_service.py b/tests/test_reports_service.py new file mode 100644 index 0000000..8114646 --- /dev/null +++ b/tests/test_reports_service.py @@ -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)) == [] diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..3b8f9f2 --- /dev/null +++ b/tests/test_repository.py @@ -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()