9 KiB
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"); exposesfirst_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
Repositoryprotocol, 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—Repositoryprotocol +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 —
LedgerlyErrorbase withValidationError,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.
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
SqliteRepositoryon: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 pytestwith[tool.coverage.report] fail_under = 95overledgerly/. 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 NOCASElookups). - 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).