Compare commits
3 commits
9e94f740de
...
f6e6542b3d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6e6542b3d | ||
|
|
c4656c0353 | ||
|
|
5b41e80ae1 |
20
.ai/guidelines/guidelines.md
Normal file
20
.ai/guidelines/guidelines.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
You are a lead/staff/senior architect/software engineer.
|
||||||
|
All of your code adheres to the SOLID, KISS, COMMAND/QUERY, and Law of Demeter principles.
|
||||||
|
|
||||||
|
If SPECS.md exists, it is the source of truth for this application. Read it NOW.
|
||||||
|
If SPECS.md doesn't exist, follow the below rules.
|
||||||
|
|
||||||
|
- Your job is to come up with an application to build.
|
||||||
|
- Any application will do. It is completely up to you. There are no rules on WHAT you can build.
|
||||||
|
- The only rules are:
|
||||||
|
- Whatever you decide to build, you will first create an application specification file for it called, SPECS.md.
|
||||||
|
- SPECS.md does not have to be built in one go. Do it little by little.
|
||||||
|
- As soon as the decision is made what to build, write that decision in SPECS.md
|
||||||
|
- SPECS.md must contain as much detail as possible about the application, including the following tech stack
|
||||||
|
- SQLite
|
||||||
|
- languages/frameworks/tools are completely up to you, but they must be specified in SPECS.md
|
||||||
|
- As more decisions are made, write them in SPECS.md.
|
||||||
|
- Once SPECS.md is completed, you will write the code to build the application.
|
||||||
|
- Do not try to write the code all at once, write it per one to five files. Not entire application in one execution.
|
||||||
|
- It must be fully tested at a code coverage of 95% or greater of application code.
|
||||||
|
- Any pre-written framework files or tool files are excluded from this coverage.
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -158,5 +158,5 @@ cython_debug/
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
# 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
|
# 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.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
.idea/
|
||||||
|
|
||||||
|
|
|
||||||
47
README.md
Normal file
47
README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Ledgerly
|
||||||
|
|
||||||
|
A tiny, zero-dependency personal finance tracker for the command line.
|
||||||
|
Record income and expense transactions, set a monthly budget per expense
|
||||||
|
category, and report where the money went. All data lives in one SQLite file;
|
||||||
|
the runtime needs nothing but the Python standard library.
|
||||||
|
|
||||||
|
See [SPECS.md](SPECS.md) — the source of truth for this application.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m ledgerly add 45.20 --category Groceries --note "weekly shop"
|
||||||
|
python -m ledgerly add 3000 --category Salary --date 2026-10-01
|
||||||
|
python -m ledgerly budget-set Rent 1500
|
||||||
|
python -m ledgerly list
|
||||||
|
python -m ledgerly budget-show --month 2026-10
|
||||||
|
python -m ledgerly report --month 2026-10
|
||||||
|
```
|
||||||
|
|
||||||
|
Data is stored in `~/.ledgerly/ledgerly.db` (override with `--db PATH`,
|
||||||
|
a global option that goes *before* the subcommand).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `add AMOUNT --category NAME [--date YYYY-MM-DD] [--note TEXT]` | record a transaction |
|
||||||
|
| `list [--month YYYY-MM] [--category NAME] [--all]` | list transactions (newest first) |
|
||||||
|
| `delete ID` | delete a transaction |
|
||||||
|
| `categories` | list categories |
|
||||||
|
| `category-add NAME --type income\|expense` | add a category |
|
||||||
|
| `category-del NAME` | delete an unused category |
|
||||||
|
| `budget-set CATEGORY AMOUNT` | set/replace the monthly budget |
|
||||||
|
| `budget-del CATEGORY` | remove a budget |
|
||||||
|
| `budget-show [--month YYYY-MM]` | budgets vs. spending (flags `OVER`, surfaces unbudgeted spending) |
|
||||||
|
| `report [--month YYYY-MM]` | income / expenses / net + category breakdown |
|
||||||
|
|
||||||
|
Exit codes: `0` success · `1` domain error · `2` usage error.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest # run the test suite
|
||||||
|
python -m coverage run -m pytest # run with coverage
|
||||||
|
python -m coverage report # report (gate: 95% of ledgerly/)
|
||||||
|
```
|
||||||
188
SPECS.md
Normal file
188
SPECS.md
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
# Ledgerly — Application Specification
|
||||||
|
|
||||||
|
Ledgerly is a single-user, command-line personal finance tracker. It records
|
||||||
|
income and expense transactions against named categories, keeps a monthly
|
||||||
|
budget per expense category, and reports monthly totals, per-category totals,
|
||||||
|
and budget status. All state lives in one SQLite file.
|
||||||
|
|
||||||
|
Status: **active — this file is the source of truth for the application.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goals
|
||||||
|
|
||||||
|
- G1 Zero runtime dependencies beyond the Python standard library.
|
||||||
|
- G2 All money math exact: integer cents; no floats in storage or domain logic.
|
||||||
|
- G3 Plain-text output that is stable and script-friendly.
|
||||||
|
- G4 Domain logic fully tested: ≥ 95% line coverage of `ledgerly/` application code.
|
||||||
|
|
||||||
|
## 2. Non-goals (v0.1)
|
||||||
|
|
||||||
|
- Web UI, authentication, multi-user.
|
||||||
|
- Multi-currency, taxes, transfers between accounts.
|
||||||
|
- Recurring transactions, reminders.
|
||||||
|
|
||||||
|
## 3. Tech stack
|
||||||
|
|
||||||
|
| Concern | Choice |
|
||||||
|
|-------------|-------------------------------------------|
|
||||||
|
| Language | Python 3.13 (supports 3.10+) |
|
||||||
|
| Storage | SQLite (≥ 3.40) via stdlib `sqlite3` |
|
||||||
|
| CLI | stdlib `argparse` (subcommands) |
|
||||||
|
| Tests | pytest 8.x |
|
||||||
|
| Coverage | coverage.py 7.x, gate at 95% |
|
||||||
|
| Packaging | PEP 621 `pyproject.toml`, no install req. |
|
||||||
|
|
||||||
|
## 4. Domain model
|
||||||
|
|
||||||
|
### 4.1 Entities
|
||||||
|
|
||||||
|
- **Category** — `name` (unique, case-insensitive, ≤ 50 chars), `kind` ∈ {income, expense}.
|
||||||
|
- **Transaction** — `id` (auto), `amount` (> 0, integer cents), `category` (must exist),
|
||||||
|
`date` (YYYY-MM-DD, 2000-01-01…2100-12-31), `note` (≤ 200 chars, optional).
|
||||||
|
- **Budget** — monthly limit for one expense category: `category` + `amount` (> 0).
|
||||||
|
A budget is defined once and applies to every month.
|
||||||
|
|
||||||
|
### 4.2 Value objects / read models
|
||||||
|
|
||||||
|
- **Money** — immutable, total-ordered, stored as signed integer cents.
|
||||||
|
`Money.parse()` accepts decimal input (≤ 2 decimals, half-up rounding).
|
||||||
|
Formats as `$1,234.56`; negatives as `-$1.00`.
|
||||||
|
- **Month** — `(year, month)` with year in 2000…2100; `Month.from_str("YYYY-MM")`;
|
||||||
|
exposes `first_day`, `last_day`, `range`.
|
||||||
|
- **MonthSummary** — month, income, expenses, `net` (= income − expenses, may be negative).
|
||||||
|
- **CategoryTotal** — category, kind, total for a period.
|
||||||
|
- **BudgetLine** — category, budget, spent; `remaining` (may be negative), `is_over`.
|
||||||
|
|
||||||
|
### 4.3 Rules
|
||||||
|
|
||||||
|
| ID | Rule |
|
||||||
|
|-----|------|
|
||||||
|
| R1 | Transaction amount must be > 0. |
|
||||||
|
| R2 | Budget amount must be > 0. |
|
||||||
|
| R3 | Amounts have at most 2 decimal places (parsed, half-up rounded). |
|
||||||
|
| R4 | Dates are valid ISO, between 2000-01-01 and 2100-12-31. |
|
||||||
|
| R5 | Category names: non-empty, ≤ 50 chars, unique case-insensitively. |
|
||||||
|
| R6 | Category kind ∈ {income, expense}. |
|
||||||
|
| R7 | Budgets may only attach to expense categories. |
|
||||||
|
| R8 | Transactions must reference an existing category. |
|
||||||
|
| R9 | A category that has transactions or a budget cannot be deleted. |
|
||||||
|
| R10 | Note ≤ 200 chars; blank notes are stored as NULL. |
|
||||||
|
| R11 | Budget report lists every budgeted expense category for the month, plus any expense category with spending but no budget (budget shown as $0). |
|
||||||
|
| R12 | Deleting a transaction requires its id; unknown id is an error. |
|
||||||
|
|
||||||
|
## 5. Architecture
|
||||||
|
|
||||||
|
Principles: SOLID, KISS, CQRS, Law of Demeter.
|
||||||
|
|
||||||
|
- **DIP / SRP** — services depend on a `Repository` protocol, not on SQLite.
|
||||||
|
One module per concern:
|
||||||
|
- `money.py` — Money value object and parsing.
|
||||||
|
- `models.py` — entities, value objects, input sanitizers (name/note/date/month).
|
||||||
|
- `db.py` — schema DDL and connection factory.
|
||||||
|
- `repository.py` — `Repository` protocol + `SqliteRepository` (only module that speaks SQL).
|
||||||
|
- `services/ledger.py` — category & transaction commands/queries.
|
||||||
|
- `services/budgets.py` — budget commands/queries.
|
||||||
|
- `services/reports.py` — read-only reporting queries.
|
||||||
|
- `cli.py` — argument parsing, dispatch, presentation (strings in, strings out).
|
||||||
|
- `__main__.py` — process entry point (`python -m ledgerly`).
|
||||||
|
- **CQRS** — every service method is either a command (state change) or a query
|
||||||
|
(read-only). Commands never return read-model aggregates; queries never mutate.
|
||||||
|
- **Law of Demeter** — call chain is strictly CLI → service → repository;
|
||||||
|
each layer sees only the next.
|
||||||
|
- **Errors** — `LedgerlyError` base with `ValidationError`, `NotFoundError`,
|
||||||
|
`ConflictError`. The CLI maps them to exit codes; no string matching.
|
||||||
|
|
||||||
|
## 6. Persistence
|
||||||
|
|
||||||
|
SQLite file with foreign keys enforced. Default location `~/.ledgerly/ledgerly.db`;
|
||||||
|
overridable with `--db PATH` (global option). Tests use `:memory:` or a temp file.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('income', 'expense'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS transactions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
|
||||||
|
category_id INTEGER NOT NULL REFERENCES categories (id),
|
||||||
|
date TEXT NOT NULL, -- YYYY-MM-DD
|
||||||
|
note TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tx_date ON transactions (date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tx_category ON transactions (category_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS budgets (
|
||||||
|
category_id INTEGER PRIMARY KEY REFERENCES categories (id),
|
||||||
|
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
First run seeds (exactly once — a `meta` marker row records that seeding
|
||||||
|
happened, so deleted categories never resurrect):
|
||||||
|
income `Salary`, `Other Income`; expense `Rent`, `Groceries`, `Utilities`,
|
||||||
|
`Dining Out`, `Transport`, `Entertainment`, `Health`, `Savings`, `Other`.
|
||||||
|
|
||||||
|
## 7. CLI reference
|
||||||
|
|
||||||
|
Global options: `--db PATH` (default `~/.ledgerly/ledgerly.db`), `--version`.
|
||||||
|
|
||||||
|
| Command | Options | Effect |
|
||||||
|
|----------------------|---------------------------------------------------------------------|--------|
|
||||||
|
| `add AMOUNT` | `--category NAME` (req), `--date YYYY-MM-DD` (default today), `--note TEXT` | record a transaction (R1, R3, R8, R10) |
|
||||||
|
| `list` | `--month YYYY-MM` (default current), `--category NAME`, `--all` | list transactions, newest first (R5, R8) |
|
||||||
|
| `delete ID` | — | delete a transaction (R12) |
|
||||||
|
| `categories` | — | list categories |
|
||||||
|
| `category-add NAME` | `--type income\|expense` (req) | add a category (R5, R6) |
|
||||||
|
| `category-del NAME` | — | delete an unused category (R9) |
|
||||||
|
| `budget-set CATEGORY AMOUNT` | — | set/replace the monthly budget (R2, R7) |
|
||||||
|
| `budget-del CATEGORY`| — | remove a budget |
|
||||||
|
| `budget-show` | `--month YYYY-MM` (default current) | budgets vs. spending for the month (R11) |
|
||||||
|
| `report` | `--month YYYY-MM` (default current) | monthly summary + category breakdown |
|
||||||
|
|
||||||
|
Exit codes: **0** success · **1** domain error (`error: …` on stderr) ·
|
||||||
|
**2** usage/parsing error (argparse).
|
||||||
|
|
||||||
|
## 8. Testing & coverage
|
||||||
|
|
||||||
|
- pytest; one test module per source module.
|
||||||
|
- Persistence exercised through `SqliteRepository` on `:memory:`.
|
||||||
|
- Services tested against the real repository (the protocol is the contract;
|
||||||
|
the real thing is the fixture).
|
||||||
|
- CLI tested by calling `main(argv, db_path=tmp)` and asserting captured
|
||||||
|
stdout/stderr and exit codes.
|
||||||
|
- Gate: `coverage run -m pytest` with `[tool.coverage.report] fail_under = 95`
|
||||||
|
over `ledgerly/`. Excluded: `ledgerly/__main__.py` (2-line entry tool file);
|
||||||
|
`tests/` and stdlib are not application code.
|
||||||
|
|
||||||
|
## 9. Milestones
|
||||||
|
|
||||||
|
- M1 — this spec.
|
||||||
|
- M2 — core value objects (`money.py`, `models.py`) + errors.
|
||||||
|
- M3 — schema + repository (`db.py`, `repository.py`).
|
||||||
|
- M4 — services (`services/ledger.py`, `services/budgets.py`, `services/reports.py`).
|
||||||
|
- M5 — CLI, entry point, `pyproject.toml`, README.
|
||||||
|
- M6 — test suite; coverage gate green.
|
||||||
|
|
||||||
|
## 10. Decisions log
|
||||||
|
|
||||||
|
- D1 Integer cents over floats: exactness, SQL CHECK constraints, no drift.
|
||||||
|
- D2 Stdlib-only runtime: KISS, zero-install, nothing to pin.
|
||||||
|
- D3 Budgets are per-category and month-agnostic (apply every month) —
|
||||||
|
simplest mental model; month is a reporting axis, not a budget axis (R11).
|
||||||
|
- D4 Month is the only time filter in v0.1 (KISS).
|
||||||
|
- D5 Category names are case-insensitively unique; stored casing is preserved
|
||||||
|
(UNIQUE COLLATE NOCASE + `COLLATE NOCASE` lookups).
|
||||||
|
- D6 Unbudgeted spending surfaces with budget $0 (R11) — no silent leaks.
|
||||||
|
- D7 CLI handlers return strings; `main()` is the only place that prints
|
||||||
|
(keeps the CLI trivially testable).
|
||||||
5
ledgerly/__init__.py
Normal file
5
ledgerly/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Ledgerly — a tiny personal finance tracker (transactions, budgets, reports)."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
8
ledgerly/__main__.py
Normal file
8
ledgerly/__main__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""Entry point for ``python -m ledgerly``."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
BIN
ledgerly/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
ledgerly/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ledgerly/__pycache__/__main__.cpython-313.pyc
Normal file
BIN
ledgerly/__pycache__/__main__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ledgerly/__pycache__/cli.cpython-313.pyc
Normal file
BIN
ledgerly/__pycache__/cli.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ledgerly/__pycache__/db.cpython-313.pyc
Normal file
BIN
ledgerly/__pycache__/db.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ledgerly/__pycache__/errors.cpython-313.pyc
Normal file
BIN
ledgerly/__pycache__/errors.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ledgerly/__pycache__/models.cpython-313.pyc
Normal file
BIN
ledgerly/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ledgerly/__pycache__/money.cpython-313.pyc
Normal file
BIN
ledgerly/__pycache__/money.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ledgerly/__pycache__/repository.cpython-313.pyc
Normal file
BIN
ledgerly/__pycache__/repository.cpython-313.pyc
Normal file
Binary file not shown.
252
ledgerly/cli.py
Normal file
252
ledgerly/cli.py
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
"""Command line interface: parsing, dispatch, and text presentation.
|
||||||
|
|
||||||
|
Handlers stay pure with respect to I/O: each returns the text to print (or
|
||||||
|
None). ``main`` is the only place that talks to stdout/stderr (D7), which
|
||||||
|
keeps the CLI trivially testable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Sequence
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .errors import LedgerlyError, ValidationError
|
||||||
|
from .money import InvalidMoney, Money
|
||||||
|
from .models import EXPENSE, INCOME, Month, Transaction, parse_date
|
||||||
|
from .repository import SqliteRepository
|
||||||
|
from .services import BudgetService, LedgerService, ReportingService
|
||||||
|
|
||||||
|
__all__ = ["build_parser", "default_db_path", "main"]
|
||||||
|
|
||||||
|
|
||||||
|
def default_db_path() -> Path:
|
||||||
|
return Path.home() / ".ledgerly" / "ledgerly.db"
|
||||||
|
|
||||||
|
|
||||||
|
# -- argument types ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def money_arg(text: str) -> Money:
|
||||||
|
try:
|
||||||
|
return Money.parse(text)
|
||||||
|
except InvalidMoney as exc:
|
||||||
|
raise argparse.ArgumentTypeError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def date_arg(text: str) -> date:
|
||||||
|
try:
|
||||||
|
return parse_date(text)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise argparse.ArgumentTypeError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def month_arg(text: str) -> Month:
|
||||||
|
try:
|
||||||
|
return Month.from_str(text)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise argparse.ArgumentTypeError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# -- parser -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="ledgerly",
|
||||||
|
description=(
|
||||||
|
"A tiny personal finance tracker: transactions, budgets, "
|
||||||
|
"monthly reports."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--version", action="version", version=f"ledgerly {__version__}"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--db",
|
||||||
|
default=None,
|
||||||
|
metavar="PATH",
|
||||||
|
help=f"database file (default: {default_db_path()})",
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
add = sub.add_parser("add", help="record a transaction")
|
||||||
|
add.add_argument("amount", type=money_arg)
|
||||||
|
add.add_argument("--category", required=True)
|
||||||
|
add.add_argument("--date", type=date_arg, default=None, help="YYYY-MM-DD (default: today)")
|
||||||
|
add.add_argument("--note", default=None)
|
||||||
|
|
||||||
|
lst = sub.add_parser("list", help="list transactions")
|
||||||
|
lst.add_argument("--month", type=month_arg, default=None, help="YYYY-MM (default: current)")
|
||||||
|
lst.add_argument("--category", default=None)
|
||||||
|
lst.add_argument("--all", action="store_true", help="ignore the month filter")
|
||||||
|
|
||||||
|
delete = sub.add_parser("delete", help="delete a transaction by id")
|
||||||
|
delete.add_argument("id", type=int)
|
||||||
|
|
||||||
|
sub.add_parser("categories", help="list categories")
|
||||||
|
|
||||||
|
cat_add = sub.add_parser("category-add", help="add a category")
|
||||||
|
cat_add.add_argument("name")
|
||||||
|
cat_add.add_argument("--type", dest="kind", required=True, choices=[INCOME, EXPENSE])
|
||||||
|
|
||||||
|
cat_del = sub.add_parser("category-del", help="delete an unused category")
|
||||||
|
cat_del.add_argument("name")
|
||||||
|
|
||||||
|
budget_set = sub.add_parser("budget-set", help="set the monthly budget for an expense category")
|
||||||
|
budget_set.add_argument("category")
|
||||||
|
budget_set.add_argument("amount", type=money_arg)
|
||||||
|
|
||||||
|
budget_del = sub.add_parser("budget-del", help="remove a budget")
|
||||||
|
budget_del.add_argument("category")
|
||||||
|
|
||||||
|
budget_show = sub.add_parser("budget-show", help="budgets vs. spending for a month")
|
||||||
|
budget_show.add_argument("--month", type=month_arg, default=None, help="YYYY-MM (default: current)")
|
||||||
|
|
||||||
|
report = sub.add_parser("report", help="monthly summary and category breakdown")
|
||||||
|
report.add_argument("--month", type=month_arg, default=None, help="YYYY-MM (default: current)")
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
# -- command handlers (return the text to print) ------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_add(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||||
|
service = LedgerService(repo)
|
||||||
|
day = args.date or date.today()
|
||||||
|
tx = service.add_transaction(args.amount, args.category, day, args.note)
|
||||||
|
note = f" ({tx.note})" if tx.note else ""
|
||||||
|
return (
|
||||||
|
f"recorded #{tx.id}: {tx.amount} in {tx.category} "
|
||||||
|
f"on {tx.date.isoformat()}{note}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_list(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||||
|
service = LedgerService(repo)
|
||||||
|
if args.all:
|
||||||
|
start = end = None
|
||||||
|
else:
|
||||||
|
start, end = (args.month or Month.now()).range
|
||||||
|
rows = service.list_transactions(start, end, args.category)
|
||||||
|
return _render_transactions(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_transactions(rows: list[Transaction]) -> str:
|
||||||
|
if not rows:
|
||||||
|
return "no transactions"
|
||||||
|
out = []
|
||||||
|
for tx in rows:
|
||||||
|
line = f"#{tx.id:<4} {tx.date.isoformat()} {tx.category:<16} {tx.kind:<8} {tx.amount}"
|
||||||
|
if tx.note:
|
||||||
|
line += f" [{tx.note}]"
|
||||||
|
out.append(line)
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_delete(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||||
|
LedgerService(repo).delete_transaction(args.id)
|
||||||
|
return f"deleted transaction #{args.id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_categories(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||||
|
cats = LedgerService(repo).list_categories()
|
||||||
|
if not cats:
|
||||||
|
return "no categories"
|
||||||
|
return "\n".join(f"{cat.kind:<8} {cat.name}" for cat in cats)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_category_add(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||||
|
cat = LedgerService(repo).add_category(args.name, args.kind)
|
||||||
|
return f"added {cat.kind} category {cat.name!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_category_del(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||||
|
LedgerService(repo).delete_category(args.name)
|
||||||
|
return f"deleted category {args.name!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_budget_set(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||||
|
budget = BudgetService(repo).set_budget(args.category, args.amount)
|
||||||
|
return f"budget for {budget.category}: {budget.amount} per month"
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_budget_del(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||||
|
budget = BudgetService(repo).delete_budget(args.category)
|
||||||
|
return f"removed budget for {budget.category}: {budget.amount}"
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_budget_show(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||||
|
month = args.month or Month.now()
|
||||||
|
lines = ReportingService(repo).budget_status(month)
|
||||||
|
out = [f"budgets for {month.label}"]
|
||||||
|
if not lines:
|
||||||
|
out.append("no budgets set, no unbudgeted spending")
|
||||||
|
for line in lines:
|
||||||
|
marker = " OVER" if line.is_over else ""
|
||||||
|
out.append(
|
||||||
|
f"{line.category:<16} budget {line.budget:<12} "
|
||||||
|
f"spent {line.spent:<12} left {line.remaining}{marker}"
|
||||||
|
)
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_report(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||||
|
month = args.month or Month.now()
|
||||||
|
service = ReportingService(repo)
|
||||||
|
summary = service.monthly_summary(month)
|
||||||
|
out = [
|
||||||
|
f"report for {month.label}",
|
||||||
|
f" income {summary.income}",
|
||||||
|
f" expenses {summary.expenses}",
|
||||||
|
f" net {summary.net}",
|
||||||
|
]
|
||||||
|
totals = service.category_totals(month)
|
||||||
|
if not totals:
|
||||||
|
out.append(" no transactions this month")
|
||||||
|
else:
|
||||||
|
current_kind = None
|
||||||
|
for total in totals:
|
||||||
|
if total.kind != current_kind:
|
||||||
|
out.append(f" {total.kind}")
|
||||||
|
current_kind = total.kind
|
||||||
|
out.append(f" {total.category:<16} {total.total}")
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
HANDLERS: dict[str, Callable[[argparse.Namespace, SqliteRepository], str]] = {
|
||||||
|
"add": _cmd_add,
|
||||||
|
"list": _cmd_list,
|
||||||
|
"delete": _cmd_delete,
|
||||||
|
"categories": _cmd_categories,
|
||||||
|
"category-add": _cmd_category_add,
|
||||||
|
"category-del": _cmd_category_del,
|
||||||
|
"budget-set": _cmd_budget_set,
|
||||||
|
"budget-del": _cmd_budget_del,
|
||||||
|
"budget-show": _cmd_budget_show,
|
||||||
|
"report": _cmd_report,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -- entry point ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None, *, db_path: str | Path | None = None) -> int:
|
||||||
|
"""Run the CLI. Returns a process exit code (SPECS.md §7)."""
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
path = str(db_path) if db_path is not None else str(args.db or default_db_path())
|
||||||
|
repo = SqliteRepository(path)
|
||||||
|
try:
|
||||||
|
output = HANDLERS[args.command](args, repo)
|
||||||
|
except LedgerlyError as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
repo.close()
|
||||||
|
if output:
|
||||||
|
print(output)
|
||||||
|
return 0
|
||||||
74
ledgerly/db.py
Normal file
74
ledgerly/db.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""SQLite schema and connection factory (SPECS.md §6)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .models import DEFAULT_CATEGORIES
|
||||||
|
|
||||||
|
__all__ = ["SCHEMA", "connect", "seed_default_categories"]
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('income', 'expense'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS transactions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
|
||||||
|
category_id INTEGER NOT NULL REFERENCES categories (id),
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
note TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tx_date ON transactions (date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tx_category ON transactions (category_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS budgets (
|
||||||
|
category_id INTEGER PRIMARY KEY REFERENCES categories (id),
|
||||||
|
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def connect(db_path: str | Path) -> sqlite3.Connection:
|
||||||
|
"""Open (and initialize) a SQLite database at *db_path*.
|
||||||
|
|
||||||
|
Creates parent directories for file databases. ``:memory:`` is allowed
|
||||||
|
for tests.
|
||||||
|
"""
|
||||||
|
path = str(db_path)
|
||||||
|
if path != ":memory:":
|
||||||
|
parent = Path(path).expanduser().parent
|
||||||
|
if parent not in (Path(""), Path(".")):
|
||||||
|
parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def seed_default_categories(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Seed built-in categories on first run only (SPECS.md §6).
|
||||||
|
|
||||||
|
A marker row in ``meta`` records that seeding happened, so deleted
|
||||||
|
categories do not resurrect even if the user deletes them all.
|
||||||
|
"""
|
||||||
|
marker = conn.execute("SELECT 1 FROM meta WHERE key = 'seeded'").fetchone()
|
||||||
|
if marker is not None:
|
||||||
|
return
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO categories (name, kind) VALUES (?, ?)",
|
||||||
|
DEFAULT_CATEGORIES,
|
||||||
|
)
|
||||||
|
conn.execute("INSERT INTO meta (key, value) VALUES ('seeded', '1')")
|
||||||
|
conn.commit()
|
||||||
24
ledgerly/errors.py
Normal file
24
ledgerly/errors.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Application error hierarchy.
|
||||||
|
|
||||||
|
Every expected failure is a subclass of :class:`LedgerlyError`, so the CLI
|
||||||
|
layer can translate failures into stable exit codes instead of inspecting
|
||||||
|
message text (single source of truth for error semantics).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__ = ["ConflictError", "LedgerlyError", "NotFoundError", "ValidationError"]
|
||||||
|
|
||||||
|
|
||||||
|
class LedgerlyError(Exception):
|
||||||
|
"""Base class for all expected Ledgerly failures."""
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(LedgerlyError):
|
||||||
|
"""Input does not satisfy a domain rule (R1-R10)."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(LedgerlyError):
|
||||||
|
"""The requested entity does not exist (R8, R12)."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictError(LedgerlyError):
|
||||||
|
"""The request conflicts with current state (e.g. a category in use, R9)."""
|
||||||
211
ledgerly/models.py
Normal file
211
ledgerly/models.py
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
"""Domain entities, value objects, and input sanitizers (SPECS.md §4)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from .errors import ValidationError
|
||||||
|
from .money import Money
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EXPENSE",
|
||||||
|
"INCOME",
|
||||||
|
"KINDS",
|
||||||
|
"DEFAULT_CATEGORIES",
|
||||||
|
"Budget",
|
||||||
|
"BudgetLine",
|
||||||
|
"Category",
|
||||||
|
"CategoryTotal",
|
||||||
|
"Month",
|
||||||
|
"MonthSummary",
|
||||||
|
"Transaction",
|
||||||
|
"clean_name",
|
||||||
|
"clean_note",
|
||||||
|
"parse_date",
|
||||||
|
"validate_kind",
|
||||||
|
]
|
||||||
|
|
||||||
|
INCOME = "income"
|
||||||
|
EXPENSE = "expense"
|
||||||
|
KINDS = (INCOME, EXPENSE)
|
||||||
|
|
||||||
|
MAX_NAME_LEN = 50
|
||||||
|
MAX_NOTE_LEN = 200
|
||||||
|
DATE_MIN = date(2000, 1, 1)
|
||||||
|
DATE_MAX = date(2100, 12, 31)
|
||||||
|
YEAR_MIN = 2000
|
||||||
|
YEAR_MAX = 2100
|
||||||
|
|
||||||
|
#: Categories seeded on first run (SPECS.md §6).
|
||||||
|
DEFAULT_CATEGORIES: tuple[tuple[str, str], ...] = (
|
||||||
|
("Salary", INCOME),
|
||||||
|
("Other Income", INCOME),
|
||||||
|
("Rent", EXPENSE),
|
||||||
|
("Groceries", EXPENSE),
|
||||||
|
("Utilities", EXPENSE),
|
||||||
|
("Dining Out", EXPENSE),
|
||||||
|
("Transport", EXPENSE),
|
||||||
|
("Entertainment", EXPENSE),
|
||||||
|
("Health", EXPENSE),
|
||||||
|
("Savings", EXPENSE),
|
||||||
|
("Other", EXPENSE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_name(text: str | None, field: str = "name") -> str:
|
||||||
|
"""Validate a category name (R5)."""
|
||||||
|
if text is None:
|
||||||
|
raise ValidationError(f"{field} must not be empty")
|
||||||
|
name = str(text).strip()
|
||||||
|
if not name:
|
||||||
|
raise ValidationError(f"{field} must not be empty")
|
||||||
|
if len(name) > MAX_NAME_LEN:
|
||||||
|
raise ValidationError(f"{field} must be at most {MAX_NAME_LEN} characters")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def clean_note(text: str | None) -> str | None:
|
||||||
|
"""Validate a transaction note (R10); blank notes become None."""
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
note = str(text).strip()
|
||||||
|
if not note:
|
||||||
|
return None
|
||||||
|
if len(note) > MAX_NOTE_LEN:
|
||||||
|
raise ValidationError(f"note must be at most {MAX_NOTE_LEN} characters")
|
||||||
|
return note
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(text: str) -> date:
|
||||||
|
"""Validate an ISO date string (R4)."""
|
||||||
|
try:
|
||||||
|
parsed = date.fromisoformat(str(text).strip())
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValidationError(f"invalid date {text!r}, expected YYYY-MM-DD") from exc
|
||||||
|
if not DATE_MIN <= parsed <= DATE_MAX:
|
||||||
|
raise ValidationError(
|
||||||
|
f"date must be between {DATE_MIN.isoformat()} and {DATE_MAX.isoformat()}"
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def validate_kind(kind: str) -> str:
|
||||||
|
"""Validate a category kind (R6)."""
|
||||||
|
if kind not in KINDS:
|
||||||
|
raise ValidationError(f"kind must be one of {', '.join(KINDS)}")
|
||||||
|
return kind
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Category:
|
||||||
|
"""A named bucket that classifies transactions (R5, R6)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Transaction:
|
||||||
|
"""A single amount of money that came in or went out (R1, R4, R8, R10)."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
amount: Money
|
||||||
|
category: str
|
||||||
|
kind: str
|
||||||
|
date: date
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Budget:
|
||||||
|
"""A monthly limit for one expense category (R2, R7)."""
|
||||||
|
|
||||||
|
category: str
|
||||||
|
amount: Money
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Month:
|
||||||
|
"""A calendar month; the reporting time axis (SPECS.md §4.2)."""
|
||||||
|
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not YEAR_MIN <= self.year <= YEAR_MAX:
|
||||||
|
raise ValidationError(f"year must be between {YEAR_MIN} and {YEAR_MAX}")
|
||||||
|
if not 1 <= self.month <= 12:
|
||||||
|
raise ValidationError("month must be between 1 and 12")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_str(cls, text: str) -> Month:
|
||||||
|
"""Parse ``YYYY-MM``; raises ValidationError on bad input."""
|
||||||
|
try:
|
||||||
|
year_text, month_text = str(text).strip().split("-")
|
||||||
|
return cls(year=int(year_text), month=int(month_text))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValidationError(f"invalid month {text!r}, expected YYYY-MM") from exc
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def now(cls) -> Month:
|
||||||
|
today = date.today()
|
||||||
|
return cls(year=today.year, month=today.month)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
return f"{self.year:04d}-{self.month:02d}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def first_day(self) -> date:
|
||||||
|
return date(self.year, self.month, 1)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_day(self) -> date:
|
||||||
|
last = calendar.monthrange(self.year, self.month)[1]
|
||||||
|
return date(self.year, self.month, last)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def range(self) -> tuple[str, str]:
|
||||||
|
"""Inclusive ISO date bounds (start, end) used by repository queries."""
|
||||||
|
return (self.first_day.isoformat(), self.last_day.isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MonthSummary:
|
||||||
|
"""Income, expenses, and net for one month (report read model)."""
|
||||||
|
|
||||||
|
month: Month
|
||||||
|
income: Money
|
||||||
|
expenses: Money
|
||||||
|
|
||||||
|
@property
|
||||||
|
def net(self) -> Money:
|
||||||
|
return self.income - self.expenses
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CategoryTotal:
|
||||||
|
"""Total movement in one category over a period (report read model)."""
|
||||||
|
|
||||||
|
category: str
|
||||||
|
kind: str
|
||||||
|
total: Money
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BudgetLine:
|
||||||
|
"""Budget vs. actual spending for one category in one month (R11)."""
|
||||||
|
|
||||||
|
category: str
|
||||||
|
budget: Money
|
||||||
|
spent: Money
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining(self) -> Money:
|
||||||
|
return self.budget - self.spent
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_over(self) -> bool:
|
||||||
|
return self.spent > self.budget
|
||||||
101
ledgerly/money.py
Normal file
101
ledgerly/money.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""Money value object.
|
||||||
|
|
||||||
|
All money in Ledgerly is stored and computed as *integer cents*. ``Money`` is
|
||||||
|
an immutable, total-ordered value object. Domain services enforce positivity
|
||||||
|
where it is required (transactions, budgets); reports may rely on negative
|
||||||
|
values for nets and remainders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
__all__ = ["InvalidMoney", "Money"]
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidMoney(ValueError):
|
||||||
|
"""Raised when text cannot be parsed as a monetary amount (R3)."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Money:
|
||||||
|
"""An immutable amount of money expressed in integer cents (signed)."""
|
||||||
|
|
||||||
|
cents: int
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if isinstance(self.cents, bool) or not isinstance(self.cents, int):
|
||||||
|
raise TypeError(f"cents must be an int, got {type(self.cents).__name__}")
|
||||||
|
|
||||||
|
# -- construction ------------------------------------------------------
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def zero(cls) -> Money:
|
||||||
|
return cls(cents=0)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, text: Union[str, int, float, Decimal]) -> Money:
|
||||||
|
"""Parse decimal text/number into Money (R3).
|
||||||
|
|
||||||
|
Accepts at most 2 decimal places; any further precision is rounded
|
||||||
|
half-up to cents. Raises :class:`InvalidMoney` for unparseable input.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
value = Decimal(str(text).strip())
|
||||||
|
except (InvalidOperation, ValueError) as exc:
|
||||||
|
raise InvalidMoney(f"not a valid amount: {text!r}") from exc
|
||||||
|
if not value.is_finite():
|
||||||
|
raise InvalidMoney(f"not a valid amount: {text!r}")
|
||||||
|
cents = int(value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) * 100)
|
||||||
|
return cls(cents=cents)
|
||||||
|
|
||||||
|
# -- predicates --------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_zero(self) -> bool:
|
||||||
|
return self.cents == 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_positive(self) -> bool:
|
||||||
|
return self.cents > 0
|
||||||
|
|
||||||
|
# -- arithmetic --------------------------------------------------------
|
||||||
|
|
||||||
|
def __add__(self, other: Money) -> Money:
|
||||||
|
if not isinstance(other, Money):
|
||||||
|
return NotImplemented
|
||||||
|
return Money(cents=self.cents + other.cents)
|
||||||
|
|
||||||
|
def __sub__(self, other: Money) -> Money:
|
||||||
|
if not isinstance(other, Money):
|
||||||
|
return NotImplemented
|
||||||
|
return Money(cents=self.cents - other.cents)
|
||||||
|
|
||||||
|
# -- ordering ----------------------------------------------------------
|
||||||
|
|
||||||
|
def __eq__(self, other: object) -> bool:
|
||||||
|
return isinstance(other, Money) and self.cents == other.cents
|
||||||
|
|
||||||
|
def __lt__(self, other: Money) -> bool:
|
||||||
|
return self.cents < other.cents
|
||||||
|
|
||||||
|
def __le__(self, other: Money) -> bool:
|
||||||
|
return self.cents <= other.cents
|
||||||
|
|
||||||
|
def __hash__(self) -> int:
|
||||||
|
return hash(("Money", self.cents))
|
||||||
|
|
||||||
|
# -- presentation ------------------------------------------------------
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
sign = "-" if self.cents < 0 else ""
|
||||||
|
return f"{sign}${abs(self.cents) / 100:,.2f}"
|
||||||
|
|
||||||
|
def __format__(self, spec: str) -> str:
|
||||||
|
"""Support alignment specs such as ``f"{money:<12}"``."""
|
||||||
|
return str.__format__(str(self), spec)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Money({self.cents})"
|
||||||
222
ledgerly/repository.py
Normal file
222
ledgerly/repository.py
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
"""Persistence layer: the ``Repository`` protocol and its SQLite implementation.
|
||||||
|
|
||||||
|
This is the only module that speaks SQL. Everything above it talks to the
|
||||||
|
``Repository`` protocol (dependency inversion), so services can be reasoned
|
||||||
|
about — and tested — without knowing SQLite exists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from .db import connect, seed_default_categories
|
||||||
|
from .errors import ConflictError
|
||||||
|
|
||||||
|
__all__ = ["Repository", "SqliteRepository"]
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class Repository(Protocol):
|
||||||
|
"""Storage contract used by the domain services (DIP)."""
|
||||||
|
|
||||||
|
# categories
|
||||||
|
def create_category(self, name: str, kind: str) -> None: ...
|
||||||
|
|
||||||
|
def find_category(self, name: str) -> tuple[int, str, str] | None: # pragma: no cover
|
||||||
|
"""Return ``(id, stored_name, kind)`` or None (case-insensitive)."""
|
||||||
|
|
||||||
|
def list_categories(self) -> list[tuple[str, str]]: ...
|
||||||
|
|
||||||
|
def category_in_use(self, category_id: int) -> bool: ...
|
||||||
|
|
||||||
|
def delete_category(self, category_id: int) -> None: ...
|
||||||
|
|
||||||
|
# transactions
|
||||||
|
def insert_transaction(
|
||||||
|
self, amount_cents: int, category_id: int, day: str, note: str | None
|
||||||
|
) -> int: ...
|
||||||
|
|
||||||
|
def list_transactions(
|
||||||
|
self, start: str | None, end: str | None, category_id: int | None
|
||||||
|
) -> list[sqlite3.Row]: ...
|
||||||
|
|
||||||
|
def transaction_exists(self, tx_id: int) -> bool: ...
|
||||||
|
|
||||||
|
def delete_transaction(self, tx_id: int) -> None: ...
|
||||||
|
|
||||||
|
# budgets
|
||||||
|
def set_budget(self, category_id: int, amount_cents: int) -> None: ...
|
||||||
|
|
||||||
|
def get_budget(self, category_id: int) -> int | None: ...
|
||||||
|
|
||||||
|
def list_budgets(self) -> list[sqlite3.Row]: ...
|
||||||
|
|
||||||
|
def delete_budget(self, category_id: int) -> bool: ...
|
||||||
|
|
||||||
|
# reporting
|
||||||
|
def income_expense_totals(self, start: str, end: str) -> tuple[int, int]: ...
|
||||||
|
|
||||||
|
def category_totals(self, start: str, end: str) -> list[sqlite3.Row]: ...
|
||||||
|
|
||||||
|
def close(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class SqliteRepository:
|
||||||
|
"""SQLite-backed repository; owns one connection for its lifetime."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str | Path) -> None:
|
||||||
|
self._conn = connect(db_path)
|
||||||
|
seed_default_categories(self._conn)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._conn.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> SqliteRepository:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc_info: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
# -- categories --------------------------------------------------------
|
||||||
|
|
||||||
|
def create_category(self, name: str, kind: str) -> None:
|
||||||
|
try:
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO categories (name, kind) VALUES (?, ?)", (name, kind)
|
||||||
|
)
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
raise ConflictError(f"category {name!r} already exists") from exc
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def find_category(self, name: str) -> tuple[int, str, str] | None:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT id, name, kind FROM categories "
|
||||||
|
"WHERE name = ? COLLATE NOCASE LIMIT 1",
|
||||||
|
(name,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return (row["id"], row["name"], row["kind"])
|
||||||
|
|
||||||
|
def list_categories(self) -> list[tuple[str, str]]:
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"SELECT name, kind FROM categories ORDER BY kind DESC, name"
|
||||||
|
).fetchall()
|
||||||
|
return [(row["name"], row["kind"]) for row in rows]
|
||||||
|
|
||||||
|
def category_in_use(self, category_id: int) -> bool:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT (SELECT COUNT(*) FROM transactions WHERE category_id = ?) "
|
||||||
|
" + (SELECT COUNT(*) FROM budgets WHERE category_id = ?) AS used",
|
||||||
|
(category_id, category_id),
|
||||||
|
).fetchone()
|
||||||
|
return bool(row["used"])
|
||||||
|
|
||||||
|
def delete_category(self, category_id: int) -> None:
|
||||||
|
self._conn.execute("DELETE FROM categories WHERE id = ?", (category_id,))
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
# -- transactions ------------------------------------------------------
|
||||||
|
|
||||||
|
def insert_transaction(
|
||||||
|
self, amount_cents: int, category_id: int, day: str, note: str | None
|
||||||
|
) -> int:
|
||||||
|
cur = self._conn.execute(
|
||||||
|
"INSERT INTO transactions (amount_cents, category_id, date, note) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(amount_cents, category_id, day, note),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
def list_transactions(
|
||||||
|
self, start: str | None, end: str | None, category_id: int | None
|
||||||
|
) -> list[sqlite3.Row]:
|
||||||
|
query = (
|
||||||
|
"SELECT t.id, t.amount_cents, t.date, t.note, "
|
||||||
|
" c.name AS category, c.kind "
|
||||||
|
"FROM transactions t JOIN categories c ON c.id = t.category_id"
|
||||||
|
)
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list[object] = []
|
||||||
|
if start is not None:
|
||||||
|
clauses.append("t.date >= ?")
|
||||||
|
params.append(start)
|
||||||
|
if end is not None:
|
||||||
|
clauses.append("t.date <= ?")
|
||||||
|
params.append(end)
|
||||||
|
if category_id is not None:
|
||||||
|
clauses.append("t.category_id = ?")
|
||||||
|
params.append(category_id)
|
||||||
|
if clauses:
|
||||||
|
query += " WHERE " + " AND ".join(clauses)
|
||||||
|
query += " ORDER BY t.date DESC, t.id DESC"
|
||||||
|
return self._conn.execute(query, params).fetchall()
|
||||||
|
|
||||||
|
def transaction_exists(self, tx_id: int) -> bool:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT 1 FROM transactions WHERE id = ?", (tx_id,)
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
def delete_transaction(self, tx_id: int) -> None:
|
||||||
|
self._conn.execute("DELETE FROM transactions WHERE id = ?", (tx_id,))
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
# -- budgets -----------------------------------------------------------
|
||||||
|
|
||||||
|
def set_budget(self, category_id: int, amount_cents: int) -> None:
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO budgets (category_id, amount_cents) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT (category_id) DO UPDATE "
|
||||||
|
"SET amount_cents = excluded.amount_cents",
|
||||||
|
(category_id, amount_cents),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def get_budget(self, category_id: int) -> int | None:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT amount_cents FROM budgets WHERE category_id = ?", (category_id,)
|
||||||
|
).fetchone()
|
||||||
|
return None if row is None else int(row["amount_cents"])
|
||||||
|
|
||||||
|
def list_budgets(self) -> list[sqlite3.Row]:
|
||||||
|
return self._conn.execute(
|
||||||
|
"SELECT c.name AS category, c.kind AS kind, b.amount_cents "
|
||||||
|
"FROM budgets b JOIN categories c ON c.id = b.category_id "
|
||||||
|
"ORDER BY c.name"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
def delete_budget(self, category_id: int) -> bool:
|
||||||
|
cur = self._conn.execute(
|
||||||
|
"DELETE FROM budgets WHERE category_id = ?", (category_id,)
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
# -- reporting ---------------------------------------------------------
|
||||||
|
|
||||||
|
def income_expense_totals(self, start: str, end: str) -> tuple[int, int]:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT COALESCE(SUM(CASE WHEN c.kind = 'income' "
|
||||||
|
" THEN t.amount_cents END), 0) AS income, "
|
||||||
|
" COALESCE(SUM(CASE WHEN c.kind = 'expense' "
|
||||||
|
" THEN t.amount_cents END), 0) AS expenses "
|
||||||
|
"FROM transactions t JOIN categories c ON c.id = t.category_id "
|
||||||
|
"WHERE t.date >= ? AND t.date <= ?",
|
||||||
|
(start, end),
|
||||||
|
).fetchone()
|
||||||
|
return (int(row["income"]), int(row["expenses"]))
|
||||||
|
|
||||||
|
def category_totals(self, start: str, end: str) -> list[sqlite3.Row]:
|
||||||
|
return self._conn.execute(
|
||||||
|
"SELECT c.name AS category, c.kind AS kind, "
|
||||||
|
" SUM(t.amount_cents) AS total_cents "
|
||||||
|
"FROM transactions t JOIN categories c ON c.id = t.category_id "
|
||||||
|
"WHERE t.date >= ? AND t.date <= ? "
|
||||||
|
"GROUP BY c.id "
|
||||||
|
"ORDER BY c.kind DESC, total_cents DESC, c.name",
|
||||||
|
(start, end),
|
||||||
|
).fetchall()
|
||||||
7
ledgerly/services/__init__.py
Normal file
7
ledgerly/services/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Domain services: the application's command and query surface."""
|
||||||
|
|
||||||
|
from .budgets import BudgetService
|
||||||
|
from .ledger import LedgerService
|
||||||
|
from .reports import ReportingService
|
||||||
|
|
||||||
|
__all__ = ["BudgetService", "LedgerService", "ReportingService"]
|
||||||
BIN
ledgerly/services/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
ledgerly/services/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ledgerly/services/__pycache__/budgets.cpython-313.pyc
Normal file
BIN
ledgerly/services/__pycache__/budgets.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ledgerly/services/__pycache__/ledger.cpython-313.pyc
Normal file
BIN
ledgerly/services/__pycache__/ledger.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ledgerly/services/__pycache__/reports.cpython-313.pyc
Normal file
BIN
ledgerly/services/__pycache__/reports.cpython-313.pyc
Normal file
Binary file not shown.
56
ledgerly/services/budgets.py
Normal file
56
ledgerly/services/budgets.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Commands and queries for monthly budgets (R2, R7)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..errors import NotFoundError, ValidationError
|
||||||
|
from ..money import Money
|
||||||
|
from ..models import INCOME, Budget, clean_name
|
||||||
|
from ..repository import Repository
|
||||||
|
|
||||||
|
__all__ = ["BudgetService"]
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetService:
|
||||||
|
"""Owns budget rules: positivity, expense-only, replace/delete semantics."""
|
||||||
|
|
||||||
|
def __init__(self, repo: Repository) -> None:
|
||||||
|
self._repo = repo
|
||||||
|
|
||||||
|
# -- commands ----------------------------------------------------------
|
||||||
|
|
||||||
|
def set_budget(self, category: str, amount: Money) -> Budget:
|
||||||
|
clean = clean_name(category, "category")
|
||||||
|
if not amount.is_positive:
|
||||||
|
raise ValidationError("budget must be greater than zero")
|
||||||
|
ref = self._repo.find_category(clean)
|
||||||
|
if ref is None:
|
||||||
|
raise NotFoundError(f"unknown category {clean!r}")
|
||||||
|
category_id, stored_name, kind = ref
|
||||||
|
if kind == INCOME:
|
||||||
|
raise ValidationError(
|
||||||
|
f"budgets apply to expense categories only "
|
||||||
|
f"({stored_name!r} is income)"
|
||||||
|
)
|
||||||
|
self._repo.set_budget(category_id, amount.cents)
|
||||||
|
return Budget(category=stored_name, amount=amount)
|
||||||
|
|
||||||
|
def delete_budget(self, category: str) -> Budget:
|
||||||
|
"""Remove the budget for *category*; returns the deleted budget."""
|
||||||
|
clean = clean_name(category, "category")
|
||||||
|
ref = self._repo.find_category(clean)
|
||||||
|
if ref is None:
|
||||||
|
raise NotFoundError(f"unknown category {clean!r}")
|
||||||
|
category_id, stored_name, _kind = ref
|
||||||
|
cents = self._repo.get_budget(category_id)
|
||||||
|
if cents is None:
|
||||||
|
raise NotFoundError(f"no budget set for {stored_name!r}")
|
||||||
|
self._repo.delete_budget(category_id)
|
||||||
|
return Budget(category=stored_name, amount=Money(cents=cents))
|
||||||
|
|
||||||
|
# -- queries -----------------------------------------------------------
|
||||||
|
|
||||||
|
def list_budgets(self) -> list[Budget]:
|
||||||
|
return [
|
||||||
|
Budget(category=row["category"], amount=Money(cents=row["amount_cents"]))
|
||||||
|
for row in self._repo.list_budgets()
|
||||||
|
]
|
||||||
102
ledgerly/services/ledger.py
Normal file
102
ledgerly/services/ledger.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""Commands and queries for categories and transactions.
|
||||||
|
|
||||||
|
CQRS: the ``add_*``/``delete_*`` methods are commands (state changes);
|
||||||
|
``list_*`` methods are queries (read-only). Neither side leaks the other.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from ..errors import ConflictError, NotFoundError, ValidationError
|
||||||
|
from ..money import Money
|
||||||
|
from ..models import Category, Transaction, clean_name, clean_note, validate_kind
|
||||||
|
from ..repository import Repository
|
||||||
|
|
||||||
|
__all__ = ["LedgerService"]
|
||||||
|
|
||||||
|
|
||||||
|
class LedgerService:
|
||||||
|
"""Owns category and transaction rules (R5-R10, R12)."""
|
||||||
|
|
||||||
|
def __init__(self, repo: Repository) -> None:
|
||||||
|
self._repo = repo
|
||||||
|
|
||||||
|
# -- commands ----------------------------------------------------------
|
||||||
|
|
||||||
|
def add_category(self, name: str, kind: str) -> Category:
|
||||||
|
clean = clean_name(name, "category")
|
||||||
|
validate_kind(kind)
|
||||||
|
if self._repo.find_category(clean) is not None:
|
||||||
|
raise ConflictError(f"category {clean!r} already exists")
|
||||||
|
self._repo.create_category(clean, kind)
|
||||||
|
return Category(name=clean, kind=kind)
|
||||||
|
|
||||||
|
def delete_category(self, name: str) -> None:
|
||||||
|
clean = clean_name(name, "category")
|
||||||
|
ref = self._repo.find_category(clean)
|
||||||
|
if ref is None:
|
||||||
|
raise NotFoundError(f"unknown category {clean!r}")
|
||||||
|
category_id, stored_name, _kind = ref
|
||||||
|
if self._repo.category_in_use(category_id):
|
||||||
|
raise ConflictError(
|
||||||
|
f"category {stored_name!r} has transactions or a budget"
|
||||||
|
)
|
||||||
|
self._repo.delete_category(category_id)
|
||||||
|
|
||||||
|
def add_transaction(
|
||||||
|
self, amount: Money, category: str, day: date, note: str | None = None
|
||||||
|
) -> Transaction:
|
||||||
|
if not amount.is_positive:
|
||||||
|
raise ValidationError("amount must be greater than zero")
|
||||||
|
ref = self._repo.find_category(category)
|
||||||
|
if ref is None:
|
||||||
|
raise NotFoundError(f"unknown category {category!r}")
|
||||||
|
category_id, stored_name, kind = ref
|
||||||
|
cleaned_note = clean_note(note)
|
||||||
|
tx_id = self._repo.insert_transaction(
|
||||||
|
amount.cents, category_id, day.isoformat(), cleaned_note
|
||||||
|
)
|
||||||
|
return Transaction(
|
||||||
|
id=tx_id,
|
||||||
|
amount=amount,
|
||||||
|
category=stored_name,
|
||||||
|
kind=kind,
|
||||||
|
date=day,
|
||||||
|
note=cleaned_note,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_transaction(self, tx_id: int) -> None:
|
||||||
|
if not self._repo.transaction_exists(tx_id):
|
||||||
|
raise NotFoundError(f"unknown transaction #{tx_id}")
|
||||||
|
self._repo.delete_transaction(tx_id)
|
||||||
|
|
||||||
|
# -- queries -----------------------------------------------------------
|
||||||
|
|
||||||
|
def list_categories(self) -> list[Category]:
|
||||||
|
return [Category(name=name, kind=kind) for name, kind in self._repo.list_categories()]
|
||||||
|
|
||||||
|
def list_transactions(
|
||||||
|
self,
|
||||||
|
start: str | None,
|
||||||
|
end: str | None,
|
||||||
|
category: str | None = None,
|
||||||
|
) -> list[Transaction]:
|
||||||
|
category_id = None
|
||||||
|
if category is not None:
|
||||||
|
ref = self._repo.find_category(category)
|
||||||
|
if ref is None:
|
||||||
|
raise NotFoundError(f"unknown category {category!r}")
|
||||||
|
category_id = ref[0]
|
||||||
|
rows = self._repo.list_transactions(start, end, category_id)
|
||||||
|
return [
|
||||||
|
Transaction(
|
||||||
|
id=row["id"],
|
||||||
|
amount=Money(cents=row["amount_cents"]),
|
||||||
|
category=row["category"],
|
||||||
|
kind=row["kind"],
|
||||||
|
date=date.fromisoformat(row["date"]),
|
||||||
|
note=row["note"],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
63
ledgerly/services/reports.py
Normal file
63
ledgerly/services/reports.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Read-only reporting over transactions and budgets (R11).
|
||||||
|
|
||||||
|
Queries only: nothing in this module mutates state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..money import Money
|
||||||
|
from ..models import EXPENSE, BudgetLine, CategoryTotal, Month, MonthSummary
|
||||||
|
from ..repository import Repository
|
||||||
|
|
||||||
|
__all__ = ["ReportingService"]
|
||||||
|
|
||||||
|
|
||||||
|
class ReportingService:
|
||||||
|
"""Monthly summaries, category breakdowns, and budget status."""
|
||||||
|
|
||||||
|
def __init__(self, repo: Repository) -> None:
|
||||||
|
self._repo = repo
|
||||||
|
|
||||||
|
def monthly_summary(self, month: Month) -> MonthSummary:
|
||||||
|
start, end = month.range
|
||||||
|
income_cents, expense_cents = self._repo.income_expense_totals(start, end)
|
||||||
|
return MonthSummary(
|
||||||
|
month=month,
|
||||||
|
income=Money(cents=income_cents),
|
||||||
|
expenses=Money(cents=expense_cents),
|
||||||
|
)
|
||||||
|
|
||||||
|
def category_totals(self, month: Month) -> list[CategoryTotal]:
|
||||||
|
start, end = month.range
|
||||||
|
return [
|
||||||
|
CategoryTotal(
|
||||||
|
category=row["category"],
|
||||||
|
kind=row["kind"],
|
||||||
|
total=Money(cents=row["total_cents"]),
|
||||||
|
)
|
||||||
|
for row in self._repo.category_totals(start, end)
|
||||||
|
]
|
||||||
|
|
||||||
|
def budget_status(self, month: Month) -> list[BudgetLine]:
|
||||||
|
"""Budgeted lines first, then unbudgeted spending (R11)."""
|
||||||
|
start, end = month.range
|
||||||
|
spent = {
|
||||||
|
row["category"]: Money(cents=row["total_cents"])
|
||||||
|
for row in self._repo.category_totals(start, end)
|
||||||
|
if row["kind"] == EXPENSE
|
||||||
|
}
|
||||||
|
lines: list[BudgetLine] = []
|
||||||
|
for row in self._repo.list_budgets():
|
||||||
|
name = row["category"]
|
||||||
|
lines.append(
|
||||||
|
BudgetLine(
|
||||||
|
category=name,
|
||||||
|
budget=Money(cents=row["amount_cents"]),
|
||||||
|
spent=spent.get(name, Money.zero()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
budgeted = {line.category for line in lines}
|
||||||
|
for name, cost in sorted(spent.items()):
|
||||||
|
if name not in budgeted and cost.is_positive:
|
||||||
|
lines.append(BudgetLine(category=name, budget=Money.zero(), spent=cost))
|
||||||
|
return lines
|
||||||
20
pyproject.toml
Normal file
20
pyproject.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
[project]
|
||||||
|
name = "ledgerly"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A tiny personal finance tracker: transactions, budgets, monthly reports. Stdlib + SQLite only."
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest>=8.0", "coverage>=7.4"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["ledgerly"]
|
||||||
|
omit = ["ledgerly/__main__.py"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
fail_under = 95
|
||||||
|
show_missing = true
|
||||||
61
tests/test_budgets_service.py
Normal file
61
tests/test_budgets_service.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""Tests for ledgerly.services.budgets (R2, R7)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ledgerly.errors import NotFoundError, ValidationError
|
||||||
|
from ledgerly.money import Money
|
||||||
|
from ledgerly.repository import SqliteRepository
|
||||||
|
from ledgerly.services import BudgetService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def service():
|
||||||
|
repo = SqliteRepository(":memory:")
|
||||||
|
yield BudgetService(repo)
|
||||||
|
repo.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_budget(service):
|
||||||
|
budget = service.set_budget("Rent", Money(150000))
|
||||||
|
assert budget.category == "Rent"
|
||||||
|
assert budget.amount == Money(150000)
|
||||||
|
assert service.list_budgets() == [budget]
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_budget_replaces(service):
|
||||||
|
service.set_budget("Rent", Money(100))
|
||||||
|
service.set_budget("Rent", Money(200))
|
||||||
|
assert [b.amount for b in service.list_budgets()] == [Money(200)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_budget_rejects_zero(service):
|
||||||
|
with pytest.raises(ValidationError, match="greater than zero"):
|
||||||
|
service.set_budget("Rent", Money(0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_budget_unknown_category(service):
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
service.set_budget("Nope", Money(100))
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_budget_rejects_income_category(service):
|
||||||
|
with pytest.raises(ValidationError, match="expense"):
|
||||||
|
service.set_budget("Salary", Money(100))
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_budget_missing_category(service):
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
service.delete_budget("Nope")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_budget_not_set(service):
|
||||||
|
with pytest.raises(NotFoundError, match="no budget"):
|
||||||
|
service.delete_budget("Rent")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_budget_ok(service):
|
||||||
|
service.set_budget("Rent", Money(100))
|
||||||
|
deleted = service.delete_budget("rent")
|
||||||
|
assert deleted.category == "Rent"
|
||||||
|
assert deleted.amount == Money(100)
|
||||||
|
assert service.list_budgets() == []
|
||||||
235
tests/test_cli.py
Normal file
235
tests/test_cli.py
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
"""End-to-end CLI tests: main() with a temp database, captured stdout/stderr."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ledgerly import __version__
|
||||||
|
from ledgerly.cli import build_parser, default_db_path, main
|
||||||
|
from ledgerly.models import DEFAULT_CATEGORIES
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def db(tmp_path):
|
||||||
|
return str(tmp_path / "test.db")
|
||||||
|
|
||||||
|
|
||||||
|
def run(db, *argv):
|
||||||
|
return main(list(argv), db_path=db)
|
||||||
|
|
||||||
|
|
||||||
|
# -- basics --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_version(db, capsys):
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
main(["--version"], db_path=db)
|
||||||
|
assert exc.value.code == 0
|
||||||
|
assert __version__ in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_command(db):
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
main([], db_path=db)
|
||||||
|
assert exc.value.code == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_db_path_under_home():
|
||||||
|
path = default_db_path()
|
||||||
|
assert path.name == "ledgerly.db"
|
||||||
|
assert ".ledgerly" in path.parts
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_exposes_all_commands():
|
||||||
|
parser = build_parser()
|
||||||
|
assert parser.prog == "ledgerly"
|
||||||
|
|
||||||
|
|
||||||
|
# -- add / list / delete ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_and_list(db, capsys):
|
||||||
|
assert run(db, "add", "82.10", "--category", "Groceries",
|
||||||
|
"--date", "2026-10-02", "--note", "weekly") == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "#1" in out and "$82.10" in out and "weekly" in out
|
||||||
|
assert run(db, "list", "--month", "2026-10") == 0
|
||||||
|
assert "Groceries" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_default_date_and_month(db, capsys):
|
||||||
|
assert run(db, "add", "10.00", "--category", "Rent") == 0
|
||||||
|
assert run(db, "list") == 0 # current month by default
|
||||||
|
assert "Rent" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_invalid_amount(db):
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
run(db, "add", "abc", "--category", "Rent")
|
||||||
|
assert exc.value.code == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_invalid_date(db):
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
run(db, "add", "1.00", "--category", "Rent", "--date", "nope")
|
||||||
|
assert exc.value.code == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_unknown_category_domain_error(db, capsys):
|
||||||
|
assert run(db, "add", "1.00", "--category", "Nope") == 1
|
||||||
|
assert "unknown category" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_zero_amount(db, capsys):
|
||||||
|
assert run(db, "add", "0", "--category", "Rent") == 1
|
||||||
|
assert "greater than zero" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_all(db, capsys):
|
||||||
|
run(db, "add", "5.00", "--category", "Rent", "--date", "2025-01-05")
|
||||||
|
run(db, "add", "6.00", "--category", "Rent", "--date", "2026-12-01")
|
||||||
|
assert run(db, "list", "--all") == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "2025-01-05" in out and "2026-12-01" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_no_transactions(db, capsys):
|
||||||
|
assert run(db, "list", "--month", "2001-01") == 0
|
||||||
|
assert "no transactions" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_by_category(db, capsys):
|
||||||
|
run(db, "add", "5.00", "--category", "Rent", "--date", "2026-10-05")
|
||||||
|
run(db, "add", "6.00", "--category", "Groceries", "--date", "2026-10-06")
|
||||||
|
capsys.readouterr() # discard add output; keep the list output clean
|
||||||
|
assert run(db, "list", "--month", "2026-10", "--category", "rent") == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Rent" in out and "Groceries" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete(db, capsys):
|
||||||
|
assert run(db, "add", "5.00", "--category", "Rent", "--date", "2026-01-05") == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
tx_id = int(out.split("#", 1)[1].split(":", 1)[0].strip())
|
||||||
|
assert run(db, "delete", str(tx_id)) == 0
|
||||||
|
assert "deleted" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_unknown(db, capsys):
|
||||||
|
assert run(db, "delete", "999") == 1
|
||||||
|
assert "unknown transaction" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
# -- categories ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_categories(db, capsys):
|
||||||
|
assert run(db, "categories") == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Salary" in out and "Rent" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_categories_empty(db, capsys):
|
||||||
|
for name, _kind in DEFAULT_CATEGORIES:
|
||||||
|
assert run(db, "category-del", name) == 0
|
||||||
|
assert run(db, "categories") == 0
|
||||||
|
assert "no categories" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_add_and_del(db, capsys):
|
||||||
|
assert run(db, "category-add", "Pets", "--type", "expense") == 0
|
||||||
|
assert "Pets" in capsys.readouterr().out
|
||||||
|
assert run(db, "category-add", "pets", "--type", "income") == 1
|
||||||
|
assert "already exists" in capsys.readouterr().err
|
||||||
|
assert run(db, "category-del", "Pets") == 0
|
||||||
|
assert "deleted category" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_add_bad_type(db):
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
run(db, "category-add", "Pets", "--type", "asset")
|
||||||
|
assert exc.value.code == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_del_in_use(db, capsys):
|
||||||
|
run(db, "add", "5.00", "--category", "Rent", "--date", "2026-01-05")
|
||||||
|
assert run(db, "category-del", "Rent") == 1
|
||||||
|
assert "transactions or a budget" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
# -- budgets -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_set_and_del(db, capsys):
|
||||||
|
assert run(db, "budget-set", "Rent", "1500") == 0
|
||||||
|
assert "$1,500.00" in capsys.readouterr().out
|
||||||
|
assert run(db, "budget-set", "rent", "2000") == 0 # replace
|
||||||
|
assert "$2,000.00" in capsys.readouterr().out
|
||||||
|
assert run(db, "budget-del", "Rent") == 0
|
||||||
|
assert "removed budget" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_set_income_category(db, capsys):
|
||||||
|
assert run(db, "budget-set", "Salary", "100") == 1
|
||||||
|
assert "expense" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_set_invalid_amount(db):
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
run(db, "budget-set", "Rent", "not-money")
|
||||||
|
assert exc.value.code == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_del_not_set(db, capsys):
|
||||||
|
assert run(db, "budget-del", "Rent") == 1
|
||||||
|
assert "no budget" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_show_with_overage(db, capsys):
|
||||||
|
run(db, "budget-set", "Rent", "1500")
|
||||||
|
run(db, "add", "1600.00", "--category", "Rent", "--date", "2026-10-05")
|
||||||
|
assert run(db, "budget-show", "--month", "2026-10") == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Rent" in out and "OVER" in out and "budgets for 2026-10" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_show_flags_unbudgeted_spending(db, capsys):
|
||||||
|
run(db, "add", "40.00", "--category", "Groceries", "--date", "2026-10-02")
|
||||||
|
assert run(db, "budget-show", "--month", "2026-10") == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Groceries" in out and "OVER" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_show_no_budgets_no_spending(db, capsys):
|
||||||
|
assert run(db, "budget-show", "--month", "2026-10") == 0
|
||||||
|
assert "no budgets set" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
# -- report ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_report(db, capsys):
|
||||||
|
run(db, "add", "3000", "--category", "Salary", "--date", "2026-10-01")
|
||||||
|
run(db, "add", "1200", "--category", "Rent", "--date", "2026-10-02")
|
||||||
|
run(db, "add", "250", "--category", "Groceries", "--date", "2026-10-03")
|
||||||
|
assert run(db, "report", "--month", "2026-10") == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "report for 2026-10" in out
|
||||||
|
assert "income" in out and "$3,000.00" in out
|
||||||
|
assert "expenses" in out and "$1,450.00" in out
|
||||||
|
assert "net" in out and "$1,550.00" in out
|
||||||
|
assert "Salary" in out and "Groceries" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_empty(db, capsys):
|
||||||
|
assert run(db, "report", "--month", "2001-01") == 0
|
||||||
|
assert "no transactions" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_default_month(db, capsys):
|
||||||
|
assert run(db, "report") == 0
|
||||||
|
assert "report for" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_month(db):
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
run(db, "report", "--month", "2026-99")
|
||||||
|
assert exc.value.code == 2
|
||||||
124
tests/test_ledger_service.py
Normal file
124
tests/test_ledger_service.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""Tests for ledgerly.services.ledger (R5-R10, R12)."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ledgerly.errors import ConflictError, NotFoundError, ValidationError
|
||||||
|
from ledgerly.models import EXPENSE, INCOME, Month
|
||||||
|
from ledgerly.money import Money
|
||||||
|
from ledgerly.repository import SqliteRepository
|
||||||
|
from ledgerly.services import LedgerService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def service():
|
||||||
|
repo = SqliteRepository(":memory:")
|
||||||
|
yield LedgerService(repo)
|
||||||
|
repo.close()
|
||||||
|
|
||||||
|
|
||||||
|
# -- commands ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_category(service):
|
||||||
|
cat = service.add_category("Pets", EXPENSE)
|
||||||
|
assert cat.name == "Pets"
|
||||||
|
assert cat.kind == EXPENSE
|
||||||
|
assert "Pets" in [c.name for c in service.list_categories()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_category_duplicate(service):
|
||||||
|
service.add_category("Pets", EXPENSE)
|
||||||
|
with pytest.raises(ConflictError):
|
||||||
|
service.add_category("pets", EXPENSE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_category_invalid_name(service):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
service.add_category(" ", EXPENSE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_category_invalid_kind(service):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
service.add_category("Pets", "asset")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_category_missing(service):
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
service.delete_category("Nope")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_category_in_use(service):
|
||||||
|
service.add_transaction(Money(100), "Rent", date(2026, 10, 1))
|
||||||
|
with pytest.raises(ConflictError, match="transactions or a budget"):
|
||||||
|
service.delete_category("Rent")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_category_ok(service):
|
||||||
|
service.delete_category("Rent")
|
||||||
|
assert all(c.name != "Rent" for c in service.list_categories())
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_transaction_ok(service):
|
||||||
|
tx = service.add_transaction(Money(8210), "Groceries", date(2026, 10, 2), " weekly ")
|
||||||
|
assert tx.id >= 1
|
||||||
|
assert tx.category == "Groceries"
|
||||||
|
assert tx.kind == EXPENSE
|
||||||
|
assert tx.note == "weekly"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_transaction_rejects_zero(service):
|
||||||
|
with pytest.raises(ValidationError, match="greater than zero"):
|
||||||
|
service.add_transaction(Money(0), "Rent", date(2026, 10, 1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_transaction_unknown_category(service):
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
service.add_transaction(Money(10), "Nope", date(2026, 10, 1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_transaction_long_note(service):
|
||||||
|
with pytest.raises(ValidationError, match="200"):
|
||||||
|
service.add_transaction(Money(10), "Rent", date(2026, 10, 1), "x" * 201)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_transaction_missing(service):
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
service.delete_transaction(9999)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_transaction_ok(service):
|
||||||
|
tx = service.add_transaction(Money(100), "Rent", date(2026, 10, 1))
|
||||||
|
service.delete_transaction(tx.id)
|
||||||
|
assert service.list_transactions(None, None) == []
|
||||||
|
|
||||||
|
|
||||||
|
# -- queries -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_transactions_filter_by_month(service):
|
||||||
|
service.add_transaction(Money(100), "Rent", date(2026, 10, 1))
|
||||||
|
service.add_transaction(Money(200), "Rent", date(2026, 11, 1))
|
||||||
|
rows = service.list_transactions(*Month(2026, 10).range)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].amount == Money(100)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_transactions_filter_by_category(service):
|
||||||
|
service.add_transaction(Money(100), "Rent", date(2026, 10, 1))
|
||||||
|
service.add_transaction(Money(200), "Groceries", date(2026, 10, 2))
|
||||||
|
rows = service.list_transactions(None, None, "rent")
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].category == "Rent"
|
||||||
|
assert rows[0].kind == EXPENSE
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_transactions_unknown_category(service):
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
service.list_transactions(None, None, "Nope")
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_transaction_allowed(service):
|
||||||
|
tx = service.add_transaction(Money(300000), "Salary", date(2026, 10, 1))
|
||||||
|
assert tx.kind == INCOME
|
||||||
120
tests/test_models.py
Normal file
120
tests/test_models.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
"""Tests for ledgerly.models (sanitizers, Month, read models)."""
|
||||||
|
|
||||||
|
from dataclasses import FrozenInstanceError
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ledgerly.errors import ValidationError
|
||||||
|
from ledgerly.models import (
|
||||||
|
BudgetLine,
|
||||||
|
Category,
|
||||||
|
Month,
|
||||||
|
MonthSummary,
|
||||||
|
clean_name,
|
||||||
|
clean_note,
|
||||||
|
parse_date,
|
||||||
|
validate_kind,
|
||||||
|
)
|
||||||
|
from ledgerly.money import Money
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_name_ok():
|
||||||
|
assert clean_name(" Rent ") == "Rent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_name_rejects_blank():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
clean_name(" ")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
clean_name(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_name_rejects_long():
|
||||||
|
with pytest.raises(ValidationError, match="50"):
|
||||||
|
clean_name("x" * 51)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_note():
|
||||||
|
assert clean_note(None) is None
|
||||||
|
assert clean_note(" ") is None
|
||||||
|
assert clean_note("a note") == "a note"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_note_rejects_long():
|
||||||
|
with pytest.raises(ValidationError, match="200"):
|
||||||
|
clean_note("n" * 201)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_date_ok():
|
||||||
|
assert parse_date("2026-10-26") == date(2026, 10, 26)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_date_rejects_bad():
|
||||||
|
for bad in ("2026-13-01", "2026-02-30", "25-10-01", "not-a-date", 123):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
parse_date(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_date_rejects_out_of_range():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
parse_date("1999-12-31")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
parse_date("2101-01-01")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_kind():
|
||||||
|
assert validate_kind("income") == "income"
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validate_kind("asset")
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_from_str_ok():
|
||||||
|
month = Month.from_str("2026-10")
|
||||||
|
assert (month.year, month.month) == (2026, 10)
|
||||||
|
assert month.label == "2026-10"
|
||||||
|
assert month.first_day == date(2026, 10, 1)
|
||||||
|
assert month.last_day == date(2026, 10, 31)
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_from_str_rejects_bad():
|
||||||
|
for bad in ("2026", "26-10", "2026-13", "2026-", "2026-1-3", "garbage", 2026):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Month.from_str(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_constructor_validation():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Month(2026, 13)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Month(1999, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_range_leap_year():
|
||||||
|
month = Month(year=2024, month=2)
|
||||||
|
assert month.range == ("2024-02-01", "2024-02-29")
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_now_is_sane():
|
||||||
|
month = Month.now()
|
||||||
|
assert 1 <= month.month <= 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_summary_net():
|
||||||
|
summary = MonthSummary(month=Month.now(), income=Money(3000), expenses=Money(3100))
|
||||||
|
assert summary.net == Money(-100)
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_line_over_and_under():
|
||||||
|
over = BudgetLine(category="Rent", budget=Money(1000), spent=Money(1200))
|
||||||
|
assert over.is_over
|
||||||
|
assert over.remaining == Money(-200)
|
||||||
|
under = BudgetLine(category="Rent", budget=Money(1000), spent=Money(800))
|
||||||
|
assert not under.is_over
|
||||||
|
assert under.remaining == Money(200)
|
||||||
|
|
||||||
|
|
||||||
|
def test_entities_are_frozen():
|
||||||
|
cat = Category(name="Rent", kind="expense")
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
cat.name = "Other"
|
||||||
98
tests/test_money.py
Normal file
98
tests/test_money.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Tests for ledgerly.money (R3, Money value semantics)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ledgerly.money import InvalidMoney, Money
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_integer_string():
|
||||||
|
assert Money.parse("42") == Money(4200)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_two_decimals():
|
||||||
|
assert Money.parse("12.34") == Money(1234)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_rounds_half_up():
|
||||||
|
assert Money.parse("12.345") == Money(1235)
|
||||||
|
assert Money.parse("12.344") == Money(1234)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_negative():
|
||||||
|
assert Money.parse("-1.50") == Money(-150)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_accepts_numbers():
|
||||||
|
assert Money.parse(3) == Money(300)
|
||||||
|
assert Money.parse(2.5) == Money(250)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_rejects_garbage():
|
||||||
|
for bad in ("abc", "", None):
|
||||||
|
with pytest.raises(InvalidMoney):
|
||||||
|
Money.parse(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_rejects_non_finite():
|
||||||
|
for bad in ("inf", "-inf", "nan"):
|
||||||
|
with pytest.raises(InvalidMoney):
|
||||||
|
Money.parse(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cents_must_be_int():
|
||||||
|
for bad in ("5", 1.5, True):
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
Money(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_predicates():
|
||||||
|
assert Money(0).is_zero
|
||||||
|
assert not Money(1).is_zero
|
||||||
|
assert Money(1).is_positive
|
||||||
|
assert not Money(0).is_positive
|
||||||
|
assert not Money(-1).is_positive
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_classmethod():
|
||||||
|
assert Money.zero() == Money(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_arithmetic():
|
||||||
|
a, b = Money(100), Money(25)
|
||||||
|
assert a + b == Money(125)
|
||||||
|
assert a - b == Money(75)
|
||||||
|
assert Money(10) - Money(20) == Money(-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_arithmetic_type_errors():
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
Money(1) + 1
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
Money(1) - "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordering_and_hash():
|
||||||
|
assert Money(1) < Money(2)
|
||||||
|
assert Money(2) <= Money(2)
|
||||||
|
assert Money(2) == Money(2)
|
||||||
|
assert hash(Money(2)) == hash(Money(2))
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_equal_to_other_types():
|
||||||
|
assert Money(1) != 1
|
||||||
|
assert Money(1) != "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_str_formatting():
|
||||||
|
assert str(Money(0)) == "$0.00"
|
||||||
|
assert str(Money(123456)) == "$1,234.56"
|
||||||
|
assert str(Money(-100)) == "-$1.00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_supports_alignment():
|
||||||
|
assert format(Money(150000), "<12") == "$1,500.00 "
|
||||||
|
assert format(Money(150000), "") == "$1,500.00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_repr():
|
||||||
|
assert repr(Money(42)) == "Money(42)"
|
||||||
76
tests/test_reports_service.py
Normal file
76
tests/test_reports_service.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""Tests for ledgerly.services.reports (R11)."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ledgerly.models import Month
|
||||||
|
from ledgerly.money import Money
|
||||||
|
from ledgerly.repository import SqliteRepository
|
||||||
|
from ledgerly.services import BudgetService, LedgerService, ReportingService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def services():
|
||||||
|
repo = SqliteRepository(":memory:")
|
||||||
|
yield LedgerService(repo), BudgetService(repo), ReportingService(repo)
|
||||||
|
repo.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_summary_empty(services):
|
||||||
|
_ledger, _budgets, reports = services
|
||||||
|
summary = reports.monthly_summary(Month(2026, 10))
|
||||||
|
assert summary.income == Money(0)
|
||||||
|
assert summary.expenses == Money(0)
|
||||||
|
assert summary.net == Money(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_summary_with_data(services):
|
||||||
|
ledger, _budgets, reports = services
|
||||||
|
ledger.add_transaction(Money(300000), "Salary", date(2026, 10, 1))
|
||||||
|
ledger.add_transaction(Money(50000), "Groceries", date(2026, 10, 5))
|
||||||
|
ledger.add_transaction(Money(1), "Rent", date(2026, 9, 5)) # out of month
|
||||||
|
summary = reports.monthly_summary(Month(2026, 10))
|
||||||
|
assert summary.income == Money(300000)
|
||||||
|
assert summary.expenses == Money(50000)
|
||||||
|
assert summary.net == Money(250000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_totals_ordering(services):
|
||||||
|
ledger, _budgets, reports = services
|
||||||
|
ledger.add_transaction(Money(300000), "Salary", date(2026, 10, 1))
|
||||||
|
ledger.add_transaction(Money(50000), "Groceries", date(2026, 10, 2))
|
||||||
|
ledger.add_transaction(Money(90000), "Rent", date(2026, 10, 3))
|
||||||
|
totals = reports.category_totals(Month(2026, 10))
|
||||||
|
assert [(t.category, t.total) for t in totals] == [
|
||||||
|
("Salary", Money(300000)),
|
||||||
|
("Rent", Money(90000)),
|
||||||
|
("Groceries", Money(50000)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_status_budgeted_and_unbudgeted(services):
|
||||||
|
ledger, budgets, reports = services
|
||||||
|
budgets.set_budget("Rent", Money(150000))
|
||||||
|
ledger.add_transaction(Money(160000), "Rent", date(2026, 10, 1)) # over
|
||||||
|
ledger.add_transaction(Money(40000), "Groceries", date(2026, 10, 2)) # unbudgeted
|
||||||
|
lines = reports.budget_status(Month(2026, 10))
|
||||||
|
by_name = {line.category: line for line in lines}
|
||||||
|
assert by_name["Rent"].is_over
|
||||||
|
assert by_name["Rent"].remaining == Money(-10000)
|
||||||
|
assert by_name["Groceries"].budget == Money(0)
|
||||||
|
assert by_name["Groceries"].is_over
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_status_within_budget(services):
|
||||||
|
ledger, budgets, reports = services
|
||||||
|
budgets.set_budget("Rent", Money(150000))
|
||||||
|
ledger.add_transaction(Money(100000), "Rent", date(2026, 10, 1))
|
||||||
|
line = reports.budget_status(Month(2026, 10))[0]
|
||||||
|
assert not line.is_over
|
||||||
|
assert line.remaining == Money(50000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_status_empty(services):
|
||||||
|
_ledger, _budgets, reports = services
|
||||||
|
assert reports.budget_status(Month(2026, 10)) == []
|
||||||
188
tests/test_repository.py
Normal file
188
tests/test_repository.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
"""Tests for ledgerly.repository (schema, seeds, CRUD, reporting queries)."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ledgerly.errors import ConflictError
|
||||||
|
from ledgerly.models import DEFAULT_CATEGORIES, EXPENSE, INCOME
|
||||||
|
from ledgerly.repository import Repository, SqliteRepository
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def repo():
|
||||||
|
r = SqliteRepository(":memory:")
|
||||||
|
yield r
|
||||||
|
r.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_implements_protocol():
|
||||||
|
r = SqliteRepository(":memory:")
|
||||||
|
assert isinstance(r, Repository)
|
||||||
|
r.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeds_default_categories(repo):
|
||||||
|
cats = repo.list_categories()
|
||||||
|
names = {name for name, _ in cats}
|
||||||
|
assert "Salary" in names
|
||||||
|
assert "Rent" in names
|
||||||
|
assert len(cats) == len(DEFAULT_CATEGORIES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_idempotent(tmp_path):
|
||||||
|
path = tmp_path / "sub" / "t.db"
|
||||||
|
first = SqliteRepository(path)
|
||||||
|
count = len(first.list_categories())
|
||||||
|
first.close()
|
||||||
|
second = SqliteRepository(path)
|
||||||
|
assert len(second.list_categories()) == count
|
||||||
|
second.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleted_categories_do_not_resurrect(tmp_path):
|
||||||
|
path = tmp_path / "t.db"
|
||||||
|
first = SqliteRepository(path)
|
||||||
|
for name, _kind in first.list_categories():
|
||||||
|
first.delete_category(first.find_category(name)[0])
|
||||||
|
first.close()
|
||||||
|
second = SqliteRepository(path)
|
||||||
|
assert second.list_categories() == []
|
||||||
|
second.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_category_duplicate_raises(repo):
|
||||||
|
with pytest.raises(ConflictError):
|
||||||
|
repo.create_category("Groceries", EXPENSE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_category_case_insensitive(repo):
|
||||||
|
_id, name, kind = repo.find_category("grocErIes")
|
||||||
|
assert name == "Groceries"
|
||||||
|
assert kind == EXPENSE
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_category_missing(repo):
|
||||||
|
assert repo.find_category("Nope") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_categories_income_first(repo):
|
||||||
|
cats = repo.list_categories()
|
||||||
|
kinds = [kind for _name, kind in cats]
|
||||||
|
assert kinds.index(INCOME) < kinds.index(EXPENSE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_in_use_transitions(repo):
|
||||||
|
rent_id = repo.find_category("Rent")[0]
|
||||||
|
assert not repo.category_in_use(rent_id)
|
||||||
|
tx_id = repo.insert_transaction(100, rent_id, "2026-10-01", None)
|
||||||
|
assert repo.category_in_use(rent_id)
|
||||||
|
repo.delete_transaction(tx_id)
|
||||||
|
assert not repo.category_in_use(rent_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_in_use_via_budget(repo):
|
||||||
|
rent_id = repo.find_category("Rent")[0]
|
||||||
|
repo.set_budget(rent_id, 1000)
|
||||||
|
assert repo.category_in_use(rent_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_category(repo):
|
||||||
|
cat_id = repo.find_category("Rent")[0]
|
||||||
|
repo.delete_category(cat_id)
|
||||||
|
assert repo.find_category("Rent") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_and_list_transactions(repo):
|
||||||
|
cat_id = repo.find_category("Groceries")[0]
|
||||||
|
repo.insert_transaction(8210, cat_id, "2026-10-02", "weekly shop")
|
||||||
|
repo.insert_transaction(4500, cat_id, "2026-10-20", None)
|
||||||
|
rows = repo.list_transactions(None, None, None)
|
||||||
|
assert rows[0]["date"] == "2026-10-20" # newest first
|
||||||
|
assert rows[0]["category"] == "Groceries"
|
||||||
|
assert rows[0]["note"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_transactions_filters(repo):
|
||||||
|
grocery_id = repo.find_category("Groceries")[0]
|
||||||
|
salary_id = repo.find_category("Salary")[0]
|
||||||
|
repo.insert_transaction(8210, grocery_id, "2026-10-02", None)
|
||||||
|
repo.insert_transaction(4500, salary_id, "2026-11-01", None)
|
||||||
|
assert len(repo.list_transactions("2026-10-01", "2026-10-31", None)) == 1
|
||||||
|
assert len(repo.list_transactions(None, None, grocery_id)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_transaction_exists_and_delete(repo):
|
||||||
|
cat_id = repo.find_category("Rent")[0]
|
||||||
|
tx_id = repo.insert_transaction(150000, cat_id, "2026-10-01", None)
|
||||||
|
assert repo.transaction_exists(tx_id)
|
||||||
|
repo.delete_transaction(tx_id)
|
||||||
|
assert not repo.transaction_exists(tx_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_enforces_positive_amount(repo):
|
||||||
|
cat_id = repo.find_category("Rent")[0]
|
||||||
|
with pytest.raises(sqlite3.IntegrityError):
|
||||||
|
repo.insert_transaction(0, cat_id, "2026-10-01", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_enforces_foreign_key(repo):
|
||||||
|
with pytest.raises(sqlite3.IntegrityError):
|
||||||
|
repo.insert_transaction(100, 99999, "2026-10-01", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_budget_upsert(repo):
|
||||||
|
cat_id = repo.find_category("Rent")[0]
|
||||||
|
repo.set_budget(cat_id, 100000)
|
||||||
|
assert repo.get_budget(cat_id) == 100000
|
||||||
|
repo.set_budget(cat_id, 120000)
|
||||||
|
assert repo.get_budget(cat_id) == 120000
|
||||||
|
assert len(repo.list_budgets()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_budget(repo):
|
||||||
|
cat_id = repo.find_category("Rent")[0]
|
||||||
|
assert not repo.delete_budget(cat_id)
|
||||||
|
repo.set_budget(cat_id, 1000)
|
||||||
|
assert repo.delete_budget(cat_id)
|
||||||
|
assert repo.get_budget(cat_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_budgets_shape(repo):
|
||||||
|
repo.set_budget(repo.find_category("Rent")[0], 12345)
|
||||||
|
row = repo.list_budgets()[0]
|
||||||
|
assert row["category"] == "Rent"
|
||||||
|
assert row["kind"] == EXPENSE
|
||||||
|
assert row["amount_cents"] == 12345
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_expense_totals_empty(repo):
|
||||||
|
assert repo.income_expense_totals("2026-10-01", "2026-10-31") == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_expense_totals(repo):
|
||||||
|
salary_id = repo.find_category("Salary")[0]
|
||||||
|
grocery_id = repo.find_category("Groceries")[0]
|
||||||
|
repo.insert_transaction(300000, salary_id, "2026-10-01", None)
|
||||||
|
repo.insert_transaction(50000, grocery_id, "2026-10-05", None)
|
||||||
|
repo.insert_transaction(7777, grocery_id, "2026-09-05", None) # out of range
|
||||||
|
assert repo.income_expense_totals("2026-10-01", "2026-10-31") == (300000, 50000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_totals_ordering(repo):
|
||||||
|
salary_id = repo.find_category("Salary")[0]
|
||||||
|
grocery_id = repo.find_category("Groceries")[0]
|
||||||
|
rent_id = repo.find_category("Rent")[0]
|
||||||
|
repo.insert_transaction(300000, salary_id, "2026-10-01", None)
|
||||||
|
repo.insert_transaction(50000, grocery_id, "2026-10-05", None)
|
||||||
|
repo.insert_transaction(99999, rent_id, "2026-10-03", None)
|
||||||
|
rows = repo.category_totals("2026-10-01", "2026-10-31")
|
||||||
|
assert [r["category"] for r in rows] == ["Salary", "Rent", "Groceries"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_manager_closes(tmp_path):
|
||||||
|
path = tmp_path / "t.db"
|
||||||
|
with SqliteRepository(path) as repo:
|
||||||
|
assert repo is not None
|
||||||
|
with pytest.raises(sqlite3.ProgrammingError):
|
||||||
|
repo.list_categories()
|
||||||
Loading…
Reference in a new issue