# 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).