Ledgerly/SPECS.md
2026-10-27 11:04:50 -05:00

189 lines
9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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