189 lines
9 KiB
Markdown
189 lines
9 KiB
Markdown
|
|
# 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).
|