Ledgerly/SPECS.md

189 lines
9 KiB
Markdown
Raw Permalink Normal View History

2026-10-27 16:04:50 +00:00
# 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).