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

9 KiB
Raw Blame History

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.

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