Compare commits
No commits in common. "f6e6542b3d650d594bdf2cb8b4ecb1653f2d252b" and "9e94f740de6da3b6cbad090f9300e70478fddebe" have entirely different histories.
f6e6542b3d
...
9e94f740de
|
|
@ -1,20 +0,0 @@
|
|||
You are a lead/staff/senior architect/software engineer.
|
||||
All of your code adheres to the SOLID, KISS, COMMAND/QUERY, and Law of Demeter principles.
|
||||
|
||||
If SPECS.md exists, it is the source of truth for this application. Read it NOW.
|
||||
If SPECS.md doesn't exist, follow the below rules.
|
||||
|
||||
- Your job is to come up with an application to build.
|
||||
- Any application will do. It is completely up to you. There are no rules on WHAT you can build.
|
||||
- The only rules are:
|
||||
- Whatever you decide to build, you will first create an application specification file for it called, SPECS.md.
|
||||
- SPECS.md does not have to be built in one go. Do it little by little.
|
||||
- As soon as the decision is made what to build, write that decision in SPECS.md
|
||||
- SPECS.md must contain as much detail as possible about the application, including the following tech stack
|
||||
- SQLite
|
||||
- languages/frameworks/tools are completely up to you, but they must be specified in SPECS.md
|
||||
- As more decisions are made, write them in SPECS.md.
|
||||
- Once SPECS.md is completed, you will write the code to build the application.
|
||||
- Do not try to write the code all at once, write it per one to five files. Not entire application in one execution.
|
||||
- It must be fully tested at a code coverage of 95% or greater of application code.
|
||||
- Any pre-written framework files or tool files are excluded from this coverage.
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -158,5 +158,5 @@ cython_debug/
|
|||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
#.idea/
|
||||
|
||||
|
|
|
|||
47
README.md
47
README.md
|
|
@ -1,47 +0,0 @@
|
|||
# Ledgerly
|
||||
|
||||
A tiny, zero-dependency personal finance tracker for the command line.
|
||||
Record income and expense transactions, set a monthly budget per expense
|
||||
category, and report where the money went. All data lives in one SQLite file;
|
||||
the runtime needs nothing but the Python standard library.
|
||||
|
||||
See [SPECS.md](SPECS.md) — the source of truth for this application.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
python -m ledgerly add 45.20 --category Groceries --note "weekly shop"
|
||||
python -m ledgerly add 3000 --category Salary --date 2026-10-01
|
||||
python -m ledgerly budget-set Rent 1500
|
||||
python -m ledgerly list
|
||||
python -m ledgerly budget-show --month 2026-10
|
||||
python -m ledgerly report --month 2026-10
|
||||
```
|
||||
|
||||
Data is stored in `~/.ledgerly/ledgerly.db` (override with `--db PATH`,
|
||||
a global option that goes *before* the subcommand).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `add AMOUNT --category NAME [--date YYYY-MM-DD] [--note TEXT]` | record a transaction |
|
||||
| `list [--month YYYY-MM] [--category NAME] [--all]` | list transactions (newest first) |
|
||||
| `delete ID` | delete a transaction |
|
||||
| `categories` | list categories |
|
||||
| `category-add NAME --type income\|expense` | add a category |
|
||||
| `category-del NAME` | delete an unused category |
|
||||
| `budget-set CATEGORY AMOUNT` | set/replace the monthly budget |
|
||||
| `budget-del CATEGORY` | remove a budget |
|
||||
| `budget-show [--month YYYY-MM]` | budgets vs. spending (flags `OVER`, surfaces unbudgeted spending) |
|
||||
| `report [--month YYYY-MM]` | income / expenses / net + category breakdown |
|
||||
|
||||
Exit codes: `0` success · `1` domain error · `2` usage error.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
python -m pytest # run the test suite
|
||||
python -m coverage run -m pytest # run with coverage
|
||||
python -m coverage report # report (gate: 95% of ledgerly/)
|
||||
```
|
||||
188
SPECS.md
188
SPECS.md
|
|
@ -1,188 +0,0 @@
|
|||
# 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).
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
"""Ledgerly — a tiny personal finance tracker (transactions, budgets, reports)."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
"""Entry point for ``python -m ledgerly``."""
|
||||
|
||||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
252
ledgerly/cli.py
252
ledgerly/cli.py
|
|
@ -1,252 +0,0 @@
|
|||
"""Command line interface: parsing, dispatch, and text presentation.
|
||||
|
||||
Handlers stay pure with respect to I/O: each returns the text to print (or
|
||||
None). ``main`` is the only place that talks to stdout/stderr (D7), which
|
||||
keeps the CLI trivially testable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Callable, Sequence
|
||||
|
||||
from . import __version__
|
||||
from .errors import LedgerlyError, ValidationError
|
||||
from .money import InvalidMoney, Money
|
||||
from .models import EXPENSE, INCOME, Month, Transaction, parse_date
|
||||
from .repository import SqliteRepository
|
||||
from .services import BudgetService, LedgerService, ReportingService
|
||||
|
||||
__all__ = ["build_parser", "default_db_path", "main"]
|
||||
|
||||
|
||||
def default_db_path() -> Path:
|
||||
return Path.home() / ".ledgerly" / "ledgerly.db"
|
||||
|
||||
|
||||
# -- argument types ----------------------------------------------------------
|
||||
|
||||
|
||||
def money_arg(text: str) -> Money:
|
||||
try:
|
||||
return Money.parse(text)
|
||||
except InvalidMoney as exc:
|
||||
raise argparse.ArgumentTypeError(str(exc)) from exc
|
||||
|
||||
|
||||
def date_arg(text: str) -> date:
|
||||
try:
|
||||
return parse_date(text)
|
||||
except ValidationError as exc:
|
||||
raise argparse.ArgumentTypeError(str(exc)) from exc
|
||||
|
||||
|
||||
def month_arg(text: str) -> Month:
|
||||
try:
|
||||
return Month.from_str(text)
|
||||
except ValidationError as exc:
|
||||
raise argparse.ArgumentTypeError(str(exc)) from exc
|
||||
|
||||
|
||||
# -- parser -------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="ledgerly",
|
||||
description=(
|
||||
"A tiny personal finance tracker: transactions, budgets, "
|
||||
"monthly reports."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", action="version", version=f"ledgerly {__version__}"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=None,
|
||||
metavar="PATH",
|
||||
help=f"database file (default: {default_db_path()})",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
add = sub.add_parser("add", help="record a transaction")
|
||||
add.add_argument("amount", type=money_arg)
|
||||
add.add_argument("--category", required=True)
|
||||
add.add_argument("--date", type=date_arg, default=None, help="YYYY-MM-DD (default: today)")
|
||||
add.add_argument("--note", default=None)
|
||||
|
||||
lst = sub.add_parser("list", help="list transactions")
|
||||
lst.add_argument("--month", type=month_arg, default=None, help="YYYY-MM (default: current)")
|
||||
lst.add_argument("--category", default=None)
|
||||
lst.add_argument("--all", action="store_true", help="ignore the month filter")
|
||||
|
||||
delete = sub.add_parser("delete", help="delete a transaction by id")
|
||||
delete.add_argument("id", type=int)
|
||||
|
||||
sub.add_parser("categories", help="list categories")
|
||||
|
||||
cat_add = sub.add_parser("category-add", help="add a category")
|
||||
cat_add.add_argument("name")
|
||||
cat_add.add_argument("--type", dest="kind", required=True, choices=[INCOME, EXPENSE])
|
||||
|
||||
cat_del = sub.add_parser("category-del", help="delete an unused category")
|
||||
cat_del.add_argument("name")
|
||||
|
||||
budget_set = sub.add_parser("budget-set", help="set the monthly budget for an expense category")
|
||||
budget_set.add_argument("category")
|
||||
budget_set.add_argument("amount", type=money_arg)
|
||||
|
||||
budget_del = sub.add_parser("budget-del", help="remove a budget")
|
||||
budget_del.add_argument("category")
|
||||
|
||||
budget_show = sub.add_parser("budget-show", help="budgets vs. spending for a month")
|
||||
budget_show.add_argument("--month", type=month_arg, default=None, help="YYYY-MM (default: current)")
|
||||
|
||||
report = sub.add_parser("report", help="monthly summary and category breakdown")
|
||||
report.add_argument("--month", type=month_arg, default=None, help="YYYY-MM (default: current)")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
# -- command handlers (return the text to print) ------------------------------
|
||||
|
||||
|
||||
def _cmd_add(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||
service = LedgerService(repo)
|
||||
day = args.date or date.today()
|
||||
tx = service.add_transaction(args.amount, args.category, day, args.note)
|
||||
note = f" ({tx.note})" if tx.note else ""
|
||||
return (
|
||||
f"recorded #{tx.id}: {tx.amount} in {tx.category} "
|
||||
f"on {tx.date.isoformat()}{note}"
|
||||
)
|
||||
|
||||
|
||||
def _cmd_list(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||
service = LedgerService(repo)
|
||||
if args.all:
|
||||
start = end = None
|
||||
else:
|
||||
start, end = (args.month or Month.now()).range
|
||||
rows = service.list_transactions(start, end, args.category)
|
||||
return _render_transactions(rows)
|
||||
|
||||
|
||||
def _render_transactions(rows: list[Transaction]) -> str:
|
||||
if not rows:
|
||||
return "no transactions"
|
||||
out = []
|
||||
for tx in rows:
|
||||
line = f"#{tx.id:<4} {tx.date.isoformat()} {tx.category:<16} {tx.kind:<8} {tx.amount}"
|
||||
if tx.note:
|
||||
line += f" [{tx.note}]"
|
||||
out.append(line)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _cmd_delete(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||
LedgerService(repo).delete_transaction(args.id)
|
||||
return f"deleted transaction #{args.id}"
|
||||
|
||||
|
||||
def _cmd_categories(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||
cats = LedgerService(repo).list_categories()
|
||||
if not cats:
|
||||
return "no categories"
|
||||
return "\n".join(f"{cat.kind:<8} {cat.name}" for cat in cats)
|
||||
|
||||
|
||||
def _cmd_category_add(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||
cat = LedgerService(repo).add_category(args.name, args.kind)
|
||||
return f"added {cat.kind} category {cat.name!r}"
|
||||
|
||||
|
||||
def _cmd_category_del(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||
LedgerService(repo).delete_category(args.name)
|
||||
return f"deleted category {args.name!r}"
|
||||
|
||||
|
||||
def _cmd_budget_set(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||
budget = BudgetService(repo).set_budget(args.category, args.amount)
|
||||
return f"budget for {budget.category}: {budget.amount} per month"
|
||||
|
||||
|
||||
def _cmd_budget_del(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||
budget = BudgetService(repo).delete_budget(args.category)
|
||||
return f"removed budget for {budget.category}: {budget.amount}"
|
||||
|
||||
|
||||
def _cmd_budget_show(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||
month = args.month or Month.now()
|
||||
lines = ReportingService(repo).budget_status(month)
|
||||
out = [f"budgets for {month.label}"]
|
||||
if not lines:
|
||||
out.append("no budgets set, no unbudgeted spending")
|
||||
for line in lines:
|
||||
marker = " OVER" if line.is_over else ""
|
||||
out.append(
|
||||
f"{line.category:<16} budget {line.budget:<12} "
|
||||
f"spent {line.spent:<12} left {line.remaining}{marker}"
|
||||
)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _cmd_report(args: argparse.Namespace, repo: SqliteRepository) -> str:
|
||||
month = args.month or Month.now()
|
||||
service = ReportingService(repo)
|
||||
summary = service.monthly_summary(month)
|
||||
out = [
|
||||
f"report for {month.label}",
|
||||
f" income {summary.income}",
|
||||
f" expenses {summary.expenses}",
|
||||
f" net {summary.net}",
|
||||
]
|
||||
totals = service.category_totals(month)
|
||||
if not totals:
|
||||
out.append(" no transactions this month")
|
||||
else:
|
||||
current_kind = None
|
||||
for total in totals:
|
||||
if total.kind != current_kind:
|
||||
out.append(f" {total.kind}")
|
||||
current_kind = total.kind
|
||||
out.append(f" {total.category:<16} {total.total}")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
HANDLERS: dict[str, Callable[[argparse.Namespace, SqliteRepository], str]] = {
|
||||
"add": _cmd_add,
|
||||
"list": _cmd_list,
|
||||
"delete": _cmd_delete,
|
||||
"categories": _cmd_categories,
|
||||
"category-add": _cmd_category_add,
|
||||
"category-del": _cmd_category_del,
|
||||
"budget-set": _cmd_budget_set,
|
||||
"budget-del": _cmd_budget_del,
|
||||
"budget-show": _cmd_budget_show,
|
||||
"report": _cmd_report,
|
||||
}
|
||||
|
||||
|
||||
# -- entry point ---------------------------------------------------------------
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None, *, db_path: str | Path | None = None) -> int:
|
||||
"""Run the CLI. Returns a process exit code (SPECS.md §7)."""
|
||||
args = build_parser().parse_args(argv)
|
||||
path = str(db_path) if db_path is not None else str(args.db or default_db_path())
|
||||
repo = SqliteRepository(path)
|
||||
try:
|
||||
output = HANDLERS[args.command](args, repo)
|
||||
except LedgerlyError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
repo.close()
|
||||
if output:
|
||||
print(output)
|
||||
return 0
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
"""SQLite schema and connection factory (SPECS.md §6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from .models import DEFAULT_CATEGORIES
|
||||
|
||||
__all__ = ["SCHEMA", "connect", "seed_default_categories"]
|
||||
|
||||
SCHEMA = """
|
||||
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,
|
||||
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
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def connect(db_path: str | Path) -> sqlite3.Connection:
|
||||
"""Open (and initialize) a SQLite database at *db_path*.
|
||||
|
||||
Creates parent directories for file databases. ``:memory:`` is allowed
|
||||
for tests.
|
||||
"""
|
||||
path = str(db_path)
|
||||
if path != ":memory:":
|
||||
parent = Path(path).expanduser().parent
|
||||
if parent not in (Path(""), Path(".")):
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.executescript(SCHEMA)
|
||||
return conn
|
||||
|
||||
|
||||
def seed_default_categories(conn: sqlite3.Connection) -> None:
|
||||
"""Seed built-in categories on first run only (SPECS.md §6).
|
||||
|
||||
A marker row in ``meta`` records that seeding happened, so deleted
|
||||
categories do not resurrect even if the user deletes them all.
|
||||
"""
|
||||
marker = conn.execute("SELECT 1 FROM meta WHERE key = 'seeded'").fetchone()
|
||||
if marker is not None:
|
||||
return
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO categories (name, kind) VALUES (?, ?)",
|
||||
DEFAULT_CATEGORIES,
|
||||
)
|
||||
conn.execute("INSERT INTO meta (key, value) VALUES ('seeded', '1')")
|
||||
conn.commit()
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
"""Application error hierarchy.
|
||||
|
||||
Every expected failure is a subclass of :class:`LedgerlyError`, so the CLI
|
||||
layer can translate failures into stable exit codes instead of inspecting
|
||||
message text (single source of truth for error semantics).
|
||||
"""
|
||||
|
||||
__all__ = ["ConflictError", "LedgerlyError", "NotFoundError", "ValidationError"]
|
||||
|
||||
|
||||
class LedgerlyError(Exception):
|
||||
"""Base class for all expected Ledgerly failures."""
|
||||
|
||||
|
||||
class ValidationError(LedgerlyError):
|
||||
"""Input does not satisfy a domain rule (R1-R10)."""
|
||||
|
||||
|
||||
class NotFoundError(LedgerlyError):
|
||||
"""The requested entity does not exist (R8, R12)."""
|
||||
|
||||
|
||||
class ConflictError(LedgerlyError):
|
||||
"""The request conflicts with current state (e.g. a category in use, R9)."""
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
"""Domain entities, value objects, and input sanitizers (SPECS.md §4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from .errors import ValidationError
|
||||
from .money import Money
|
||||
|
||||
__all__ = [
|
||||
"EXPENSE",
|
||||
"INCOME",
|
||||
"KINDS",
|
||||
"DEFAULT_CATEGORIES",
|
||||
"Budget",
|
||||
"BudgetLine",
|
||||
"Category",
|
||||
"CategoryTotal",
|
||||
"Month",
|
||||
"MonthSummary",
|
||||
"Transaction",
|
||||
"clean_name",
|
||||
"clean_note",
|
||||
"parse_date",
|
||||
"validate_kind",
|
||||
]
|
||||
|
||||
INCOME = "income"
|
||||
EXPENSE = "expense"
|
||||
KINDS = (INCOME, EXPENSE)
|
||||
|
||||
MAX_NAME_LEN = 50
|
||||
MAX_NOTE_LEN = 200
|
||||
DATE_MIN = date(2000, 1, 1)
|
||||
DATE_MAX = date(2100, 12, 31)
|
||||
YEAR_MIN = 2000
|
||||
YEAR_MAX = 2100
|
||||
|
||||
#: Categories seeded on first run (SPECS.md §6).
|
||||
DEFAULT_CATEGORIES: tuple[tuple[str, str], ...] = (
|
||||
("Salary", INCOME),
|
||||
("Other Income", INCOME),
|
||||
("Rent", EXPENSE),
|
||||
("Groceries", EXPENSE),
|
||||
("Utilities", EXPENSE),
|
||||
("Dining Out", EXPENSE),
|
||||
("Transport", EXPENSE),
|
||||
("Entertainment", EXPENSE),
|
||||
("Health", EXPENSE),
|
||||
("Savings", EXPENSE),
|
||||
("Other", EXPENSE),
|
||||
)
|
||||
|
||||
|
||||
def clean_name(text: str | None, field: str = "name") -> str:
|
||||
"""Validate a category name (R5)."""
|
||||
if text is None:
|
||||
raise ValidationError(f"{field} must not be empty")
|
||||
name = str(text).strip()
|
||||
if not name:
|
||||
raise ValidationError(f"{field} must not be empty")
|
||||
if len(name) > MAX_NAME_LEN:
|
||||
raise ValidationError(f"{field} must be at most {MAX_NAME_LEN} characters")
|
||||
return name
|
||||
|
||||
|
||||
def clean_note(text: str | None) -> str | None:
|
||||
"""Validate a transaction note (R10); blank notes become None."""
|
||||
if text is None:
|
||||
return None
|
||||
note = str(text).strip()
|
||||
if not note:
|
||||
return None
|
||||
if len(note) > MAX_NOTE_LEN:
|
||||
raise ValidationError(f"note must be at most {MAX_NOTE_LEN} characters")
|
||||
return note
|
||||
|
||||
|
||||
def parse_date(text: str) -> date:
|
||||
"""Validate an ISO date string (R4)."""
|
||||
try:
|
||||
parsed = date.fromisoformat(str(text).strip())
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError(f"invalid date {text!r}, expected YYYY-MM-DD") from exc
|
||||
if not DATE_MIN <= parsed <= DATE_MAX:
|
||||
raise ValidationError(
|
||||
f"date must be between {DATE_MIN.isoformat()} and {DATE_MAX.isoformat()}"
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def validate_kind(kind: str) -> str:
|
||||
"""Validate a category kind (R6)."""
|
||||
if kind not in KINDS:
|
||||
raise ValidationError(f"kind must be one of {', '.join(KINDS)}")
|
||||
return kind
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Category:
|
||||
"""A named bucket that classifies transactions (R5, R6)."""
|
||||
|
||||
name: str
|
||||
kind: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Transaction:
|
||||
"""A single amount of money that came in or went out (R1, R4, R8, R10)."""
|
||||
|
||||
id: int
|
||||
amount: Money
|
||||
category: str
|
||||
kind: str
|
||||
date: date
|
||||
note: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Budget:
|
||||
"""A monthly limit for one expense category (R2, R7)."""
|
||||
|
||||
category: str
|
||||
amount: Money
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Month:
|
||||
"""A calendar month; the reporting time axis (SPECS.md §4.2)."""
|
||||
|
||||
year: int
|
||||
month: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not YEAR_MIN <= self.year <= YEAR_MAX:
|
||||
raise ValidationError(f"year must be between {YEAR_MIN} and {YEAR_MAX}")
|
||||
if not 1 <= self.month <= 12:
|
||||
raise ValidationError("month must be between 1 and 12")
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, text: str) -> Month:
|
||||
"""Parse ``YYYY-MM``; raises ValidationError on bad input."""
|
||||
try:
|
||||
year_text, month_text = str(text).strip().split("-")
|
||||
return cls(year=int(year_text), month=int(month_text))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError(f"invalid month {text!r}, expected YYYY-MM") from exc
|
||||
|
||||
@classmethod
|
||||
def now(cls) -> Month:
|
||||
today = date.today()
|
||||
return cls(year=today.year, month=today.month)
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return f"{self.year:04d}-{self.month:02d}"
|
||||
|
||||
@property
|
||||
def first_day(self) -> date:
|
||||
return date(self.year, self.month, 1)
|
||||
|
||||
@property
|
||||
def last_day(self) -> date:
|
||||
last = calendar.monthrange(self.year, self.month)[1]
|
||||
return date(self.year, self.month, last)
|
||||
|
||||
@property
|
||||
def range(self) -> tuple[str, str]:
|
||||
"""Inclusive ISO date bounds (start, end) used by repository queries."""
|
||||
return (self.first_day.isoformat(), self.last_day.isoformat())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonthSummary:
|
||||
"""Income, expenses, and net for one month (report read model)."""
|
||||
|
||||
month: Month
|
||||
income: Money
|
||||
expenses: Money
|
||||
|
||||
@property
|
||||
def net(self) -> Money:
|
||||
return self.income - self.expenses
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CategoryTotal:
|
||||
"""Total movement in one category over a period (report read model)."""
|
||||
|
||||
category: str
|
||||
kind: str
|
||||
total: Money
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BudgetLine:
|
||||
"""Budget vs. actual spending for one category in one month (R11)."""
|
||||
|
||||
category: str
|
||||
budget: Money
|
||||
spent: Money
|
||||
|
||||
@property
|
||||
def remaining(self) -> Money:
|
||||
return self.budget - self.spent
|
||||
|
||||
@property
|
||||
def is_over(self) -> bool:
|
||||
return self.spent > self.budget
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
"""Money value object.
|
||||
|
||||
All money in Ledgerly is stored and computed as *integer cents*. ``Money`` is
|
||||
an immutable, total-ordered value object. Domain services enforce positivity
|
||||
where it is required (transactions, budgets); reports may rely on negative
|
||||
values for nets and remainders.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from typing import Union
|
||||
|
||||
__all__ = ["InvalidMoney", "Money"]
|
||||
|
||||
|
||||
class InvalidMoney(ValueError):
|
||||
"""Raised when text cannot be parsed as a monetary amount (R3)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Money:
|
||||
"""An immutable amount of money expressed in integer cents (signed)."""
|
||||
|
||||
cents: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if isinstance(self.cents, bool) or not isinstance(self.cents, int):
|
||||
raise TypeError(f"cents must be an int, got {type(self.cents).__name__}")
|
||||
|
||||
# -- construction ------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def zero(cls) -> Money:
|
||||
return cls(cents=0)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, text: Union[str, int, float, Decimal]) -> Money:
|
||||
"""Parse decimal text/number into Money (R3).
|
||||
|
||||
Accepts at most 2 decimal places; any further precision is rounded
|
||||
half-up to cents. Raises :class:`InvalidMoney` for unparseable input.
|
||||
"""
|
||||
try:
|
||||
value = Decimal(str(text).strip())
|
||||
except (InvalidOperation, ValueError) as exc:
|
||||
raise InvalidMoney(f"not a valid amount: {text!r}") from exc
|
||||
if not value.is_finite():
|
||||
raise InvalidMoney(f"not a valid amount: {text!r}")
|
||||
cents = int(value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) * 100)
|
||||
return cls(cents=cents)
|
||||
|
||||
# -- predicates --------------------------------------------------------
|
||||
|
||||
@property
|
||||
def is_zero(self) -> bool:
|
||||
return self.cents == 0
|
||||
|
||||
@property
|
||||
def is_positive(self) -> bool:
|
||||
return self.cents > 0
|
||||
|
||||
# -- arithmetic --------------------------------------------------------
|
||||
|
||||
def __add__(self, other: Money) -> Money:
|
||||
if not isinstance(other, Money):
|
||||
return NotImplemented
|
||||
return Money(cents=self.cents + other.cents)
|
||||
|
||||
def __sub__(self, other: Money) -> Money:
|
||||
if not isinstance(other, Money):
|
||||
return NotImplemented
|
||||
return Money(cents=self.cents - other.cents)
|
||||
|
||||
# -- ordering ----------------------------------------------------------
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return isinstance(other, Money) and self.cents == other.cents
|
||||
|
||||
def __lt__(self, other: Money) -> bool:
|
||||
return self.cents < other.cents
|
||||
|
||||
def __le__(self, other: Money) -> bool:
|
||||
return self.cents <= other.cents
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(("Money", self.cents))
|
||||
|
||||
# -- presentation ------------------------------------------------------
|
||||
|
||||
def __str__(self) -> str:
|
||||
sign = "-" if self.cents < 0 else ""
|
||||
return f"{sign}${abs(self.cents) / 100:,.2f}"
|
||||
|
||||
def __format__(self, spec: str) -> str:
|
||||
"""Support alignment specs such as ``f"{money:<12}"``."""
|
||||
return str.__format__(str(self), spec)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Money({self.cents})"
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
"""Persistence layer: the ``Repository`` protocol and its SQLite implementation.
|
||||
|
||||
This is the only module that speaks SQL. Everything above it talks to the
|
||||
``Repository`` protocol (dependency inversion), so services can be reasoned
|
||||
about — and tested — without knowing SQLite exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from .db import connect, seed_default_categories
|
||||
from .errors import ConflictError
|
||||
|
||||
__all__ = ["Repository", "SqliteRepository"]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Repository(Protocol):
|
||||
"""Storage contract used by the domain services (DIP)."""
|
||||
|
||||
# categories
|
||||
def create_category(self, name: str, kind: str) -> None: ...
|
||||
|
||||
def find_category(self, name: str) -> tuple[int, str, str] | None: # pragma: no cover
|
||||
"""Return ``(id, stored_name, kind)`` or None (case-insensitive)."""
|
||||
|
||||
def list_categories(self) -> list[tuple[str, str]]: ...
|
||||
|
||||
def category_in_use(self, category_id: int) -> bool: ...
|
||||
|
||||
def delete_category(self, category_id: int) -> None: ...
|
||||
|
||||
# transactions
|
||||
def insert_transaction(
|
||||
self, amount_cents: int, category_id: int, day: str, note: str | None
|
||||
) -> int: ...
|
||||
|
||||
def list_transactions(
|
||||
self, start: str | None, end: str | None, category_id: int | None
|
||||
) -> list[sqlite3.Row]: ...
|
||||
|
||||
def transaction_exists(self, tx_id: int) -> bool: ...
|
||||
|
||||
def delete_transaction(self, tx_id: int) -> None: ...
|
||||
|
||||
# budgets
|
||||
def set_budget(self, category_id: int, amount_cents: int) -> None: ...
|
||||
|
||||
def get_budget(self, category_id: int) -> int | None: ...
|
||||
|
||||
def list_budgets(self) -> list[sqlite3.Row]: ...
|
||||
|
||||
def delete_budget(self, category_id: int) -> bool: ...
|
||||
|
||||
# reporting
|
||||
def income_expense_totals(self, start: str, end: str) -> tuple[int, int]: ...
|
||||
|
||||
def category_totals(self, start: str, end: str) -> list[sqlite3.Row]: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
class SqliteRepository:
|
||||
"""SQLite-backed repository; owns one connection for its lifetime."""
|
||||
|
||||
def __init__(self, db_path: str | Path) -> None:
|
||||
self._conn = connect(db_path)
|
||||
seed_default_categories(self._conn)
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
def __enter__(self) -> SqliteRepository:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
self.close()
|
||||
|
||||
# -- categories --------------------------------------------------------
|
||||
|
||||
def create_category(self, name: str, kind: str) -> None:
|
||||
try:
|
||||
self._conn.execute(
|
||||
"INSERT INTO categories (name, kind) VALUES (?, ?)", (name, kind)
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ConflictError(f"category {name!r} already exists") from exc
|
||||
self._conn.commit()
|
||||
|
||||
def find_category(self, name: str) -> tuple[int, str, str] | None:
|
||||
row = self._conn.execute(
|
||||
"SELECT id, name, kind FROM categories "
|
||||
"WHERE name = ? COLLATE NOCASE LIMIT 1",
|
||||
(name,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return (row["id"], row["name"], row["kind"])
|
||||
|
||||
def list_categories(self) -> list[tuple[str, str]]:
|
||||
rows = self._conn.execute(
|
||||
"SELECT name, kind FROM categories ORDER BY kind DESC, name"
|
||||
).fetchall()
|
||||
return [(row["name"], row["kind"]) for row in rows]
|
||||
|
||||
def category_in_use(self, category_id: int) -> bool:
|
||||
row = self._conn.execute(
|
||||
"SELECT (SELECT COUNT(*) FROM transactions WHERE category_id = ?) "
|
||||
" + (SELECT COUNT(*) FROM budgets WHERE category_id = ?) AS used",
|
||||
(category_id, category_id),
|
||||
).fetchone()
|
||||
return bool(row["used"])
|
||||
|
||||
def delete_category(self, category_id: int) -> None:
|
||||
self._conn.execute("DELETE FROM categories WHERE id = ?", (category_id,))
|
||||
self._conn.commit()
|
||||
|
||||
# -- transactions ------------------------------------------------------
|
||||
|
||||
def insert_transaction(
|
||||
self, amount_cents: int, category_id: int, day: str, note: str | None
|
||||
) -> int:
|
||||
cur = self._conn.execute(
|
||||
"INSERT INTO transactions (amount_cents, category_id, date, note) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(amount_cents, category_id, day, note),
|
||||
)
|
||||
self._conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
def list_transactions(
|
||||
self, start: str | None, end: str | None, category_id: int | None
|
||||
) -> list[sqlite3.Row]:
|
||||
query = (
|
||||
"SELECT t.id, t.amount_cents, t.date, t.note, "
|
||||
" c.name AS category, c.kind "
|
||||
"FROM transactions t JOIN categories c ON c.id = t.category_id"
|
||||
)
|
||||
clauses: list[str] = []
|
||||
params: list[object] = []
|
||||
if start is not None:
|
||||
clauses.append("t.date >= ?")
|
||||
params.append(start)
|
||||
if end is not None:
|
||||
clauses.append("t.date <= ?")
|
||||
params.append(end)
|
||||
if category_id is not None:
|
||||
clauses.append("t.category_id = ?")
|
||||
params.append(category_id)
|
||||
if clauses:
|
||||
query += " WHERE " + " AND ".join(clauses)
|
||||
query += " ORDER BY t.date DESC, t.id DESC"
|
||||
return self._conn.execute(query, params).fetchall()
|
||||
|
||||
def transaction_exists(self, tx_id: int) -> bool:
|
||||
row = self._conn.execute(
|
||||
"SELECT 1 FROM transactions WHERE id = ?", (tx_id,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def delete_transaction(self, tx_id: int) -> None:
|
||||
self._conn.execute("DELETE FROM transactions WHERE id = ?", (tx_id,))
|
||||
self._conn.commit()
|
||||
|
||||
# -- budgets -----------------------------------------------------------
|
||||
|
||||
def set_budget(self, category_id: int, amount_cents: int) -> None:
|
||||
self._conn.execute(
|
||||
"INSERT INTO budgets (category_id, amount_cents) VALUES (?, ?) "
|
||||
"ON CONFLICT (category_id) DO UPDATE "
|
||||
"SET amount_cents = excluded.amount_cents",
|
||||
(category_id, amount_cents),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def get_budget(self, category_id: int) -> int | None:
|
||||
row = self._conn.execute(
|
||||
"SELECT amount_cents FROM budgets WHERE category_id = ?", (category_id,)
|
||||
).fetchone()
|
||||
return None if row is None else int(row["amount_cents"])
|
||||
|
||||
def list_budgets(self) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"SELECT c.name AS category, c.kind AS kind, b.amount_cents "
|
||||
"FROM budgets b JOIN categories c ON c.id = b.category_id "
|
||||
"ORDER BY c.name"
|
||||
).fetchall()
|
||||
|
||||
def delete_budget(self, category_id: int) -> bool:
|
||||
cur = self._conn.execute(
|
||||
"DELETE FROM budgets WHERE category_id = ?", (category_id,)
|
||||
)
|
||||
self._conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
# -- reporting ---------------------------------------------------------
|
||||
|
||||
def income_expense_totals(self, start: str, end: str) -> tuple[int, int]:
|
||||
row = self._conn.execute(
|
||||
"SELECT COALESCE(SUM(CASE WHEN c.kind = 'income' "
|
||||
" THEN t.amount_cents END), 0) AS income, "
|
||||
" COALESCE(SUM(CASE WHEN c.kind = 'expense' "
|
||||
" THEN t.amount_cents END), 0) AS expenses "
|
||||
"FROM transactions t JOIN categories c ON c.id = t.category_id "
|
||||
"WHERE t.date >= ? AND t.date <= ?",
|
||||
(start, end),
|
||||
).fetchone()
|
||||
return (int(row["income"]), int(row["expenses"]))
|
||||
|
||||
def category_totals(self, start: str, end: str) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"SELECT c.name AS category, c.kind AS kind, "
|
||||
" SUM(t.amount_cents) AS total_cents "
|
||||
"FROM transactions t JOIN categories c ON c.id = t.category_id "
|
||||
"WHERE t.date >= ? AND t.date <= ? "
|
||||
"GROUP BY c.id "
|
||||
"ORDER BY c.kind DESC, total_cents DESC, c.name",
|
||||
(start, end),
|
||||
).fetchall()
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
"""Domain services: the application's command and query surface."""
|
||||
|
||||
from .budgets import BudgetService
|
||||
from .ledger import LedgerService
|
||||
from .reports import ReportingService
|
||||
|
||||
__all__ = ["BudgetService", "LedgerService", "ReportingService"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,56 +0,0 @@
|
|||
"""Commands and queries for monthly budgets (R2, R7)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import NotFoundError, ValidationError
|
||||
from ..money import Money
|
||||
from ..models import INCOME, Budget, clean_name
|
||||
from ..repository import Repository
|
||||
|
||||
__all__ = ["BudgetService"]
|
||||
|
||||
|
||||
class BudgetService:
|
||||
"""Owns budget rules: positivity, expense-only, replace/delete semantics."""
|
||||
|
||||
def __init__(self, repo: Repository) -> None:
|
||||
self._repo = repo
|
||||
|
||||
# -- commands ----------------------------------------------------------
|
||||
|
||||
def set_budget(self, category: str, amount: Money) -> Budget:
|
||||
clean = clean_name(category, "category")
|
||||
if not amount.is_positive:
|
||||
raise ValidationError("budget must be greater than zero")
|
||||
ref = self._repo.find_category(clean)
|
||||
if ref is None:
|
||||
raise NotFoundError(f"unknown category {clean!r}")
|
||||
category_id, stored_name, kind = ref
|
||||
if kind == INCOME:
|
||||
raise ValidationError(
|
||||
f"budgets apply to expense categories only "
|
||||
f"({stored_name!r} is income)"
|
||||
)
|
||||
self._repo.set_budget(category_id, amount.cents)
|
||||
return Budget(category=stored_name, amount=amount)
|
||||
|
||||
def delete_budget(self, category: str) -> Budget:
|
||||
"""Remove the budget for *category*; returns the deleted budget."""
|
||||
clean = clean_name(category, "category")
|
||||
ref = self._repo.find_category(clean)
|
||||
if ref is None:
|
||||
raise NotFoundError(f"unknown category {clean!r}")
|
||||
category_id, stored_name, _kind = ref
|
||||
cents = self._repo.get_budget(category_id)
|
||||
if cents is None:
|
||||
raise NotFoundError(f"no budget set for {stored_name!r}")
|
||||
self._repo.delete_budget(category_id)
|
||||
return Budget(category=stored_name, amount=Money(cents=cents))
|
||||
|
||||
# -- queries -----------------------------------------------------------
|
||||
|
||||
def list_budgets(self) -> list[Budget]:
|
||||
return [
|
||||
Budget(category=row["category"], amount=Money(cents=row["amount_cents"]))
|
||||
for row in self._repo.list_budgets()
|
||||
]
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
"""Commands and queries for categories and transactions.
|
||||
|
||||
CQRS: the ``add_*``/``delete_*`` methods are commands (state changes);
|
||||
``list_*`` methods are queries (read-only). Neither side leaks the other.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from ..errors import ConflictError, NotFoundError, ValidationError
|
||||
from ..money import Money
|
||||
from ..models import Category, Transaction, clean_name, clean_note, validate_kind
|
||||
from ..repository import Repository
|
||||
|
||||
__all__ = ["LedgerService"]
|
||||
|
||||
|
||||
class LedgerService:
|
||||
"""Owns category and transaction rules (R5-R10, R12)."""
|
||||
|
||||
def __init__(self, repo: Repository) -> None:
|
||||
self._repo = repo
|
||||
|
||||
# -- commands ----------------------------------------------------------
|
||||
|
||||
def add_category(self, name: str, kind: str) -> Category:
|
||||
clean = clean_name(name, "category")
|
||||
validate_kind(kind)
|
||||
if self._repo.find_category(clean) is not None:
|
||||
raise ConflictError(f"category {clean!r} already exists")
|
||||
self._repo.create_category(clean, kind)
|
||||
return Category(name=clean, kind=kind)
|
||||
|
||||
def delete_category(self, name: str) -> None:
|
||||
clean = clean_name(name, "category")
|
||||
ref = self._repo.find_category(clean)
|
||||
if ref is None:
|
||||
raise NotFoundError(f"unknown category {clean!r}")
|
||||
category_id, stored_name, _kind = ref
|
||||
if self._repo.category_in_use(category_id):
|
||||
raise ConflictError(
|
||||
f"category {stored_name!r} has transactions or a budget"
|
||||
)
|
||||
self._repo.delete_category(category_id)
|
||||
|
||||
def add_transaction(
|
||||
self, amount: Money, category: str, day: date, note: str | None = None
|
||||
) -> Transaction:
|
||||
if not amount.is_positive:
|
||||
raise ValidationError("amount must be greater than zero")
|
||||
ref = self._repo.find_category(category)
|
||||
if ref is None:
|
||||
raise NotFoundError(f"unknown category {category!r}")
|
||||
category_id, stored_name, kind = ref
|
||||
cleaned_note = clean_note(note)
|
||||
tx_id = self._repo.insert_transaction(
|
||||
amount.cents, category_id, day.isoformat(), cleaned_note
|
||||
)
|
||||
return Transaction(
|
||||
id=tx_id,
|
||||
amount=amount,
|
||||
category=stored_name,
|
||||
kind=kind,
|
||||
date=day,
|
||||
note=cleaned_note,
|
||||
)
|
||||
|
||||
def delete_transaction(self, tx_id: int) -> None:
|
||||
if not self._repo.transaction_exists(tx_id):
|
||||
raise NotFoundError(f"unknown transaction #{tx_id}")
|
||||
self._repo.delete_transaction(tx_id)
|
||||
|
||||
# -- queries -----------------------------------------------------------
|
||||
|
||||
def list_categories(self) -> list[Category]:
|
||||
return [Category(name=name, kind=kind) for name, kind in self._repo.list_categories()]
|
||||
|
||||
def list_transactions(
|
||||
self,
|
||||
start: str | None,
|
||||
end: str | None,
|
||||
category: str | None = None,
|
||||
) -> list[Transaction]:
|
||||
category_id = None
|
||||
if category is not None:
|
||||
ref = self._repo.find_category(category)
|
||||
if ref is None:
|
||||
raise NotFoundError(f"unknown category {category!r}")
|
||||
category_id = ref[0]
|
||||
rows = self._repo.list_transactions(start, end, category_id)
|
||||
return [
|
||||
Transaction(
|
||||
id=row["id"],
|
||||
amount=Money(cents=row["amount_cents"]),
|
||||
category=row["category"],
|
||||
kind=row["kind"],
|
||||
date=date.fromisoformat(row["date"]),
|
||||
note=row["note"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
"""Read-only reporting over transactions and budgets (R11).
|
||||
|
||||
Queries only: nothing in this module mutates state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..money import Money
|
||||
from ..models import EXPENSE, BudgetLine, CategoryTotal, Month, MonthSummary
|
||||
from ..repository import Repository
|
||||
|
||||
__all__ = ["ReportingService"]
|
||||
|
||||
|
||||
class ReportingService:
|
||||
"""Monthly summaries, category breakdowns, and budget status."""
|
||||
|
||||
def __init__(self, repo: Repository) -> None:
|
||||
self._repo = repo
|
||||
|
||||
def monthly_summary(self, month: Month) -> MonthSummary:
|
||||
start, end = month.range
|
||||
income_cents, expense_cents = self._repo.income_expense_totals(start, end)
|
||||
return MonthSummary(
|
||||
month=month,
|
||||
income=Money(cents=income_cents),
|
||||
expenses=Money(cents=expense_cents),
|
||||
)
|
||||
|
||||
def category_totals(self, month: Month) -> list[CategoryTotal]:
|
||||
start, end = month.range
|
||||
return [
|
||||
CategoryTotal(
|
||||
category=row["category"],
|
||||
kind=row["kind"],
|
||||
total=Money(cents=row["total_cents"]),
|
||||
)
|
||||
for row in self._repo.category_totals(start, end)
|
||||
]
|
||||
|
||||
def budget_status(self, month: Month) -> list[BudgetLine]:
|
||||
"""Budgeted lines first, then unbudgeted spending (R11)."""
|
||||
start, end = month.range
|
||||
spent = {
|
||||
row["category"]: Money(cents=row["total_cents"])
|
||||
for row in self._repo.category_totals(start, end)
|
||||
if row["kind"] == EXPENSE
|
||||
}
|
||||
lines: list[BudgetLine] = []
|
||||
for row in self._repo.list_budgets():
|
||||
name = row["category"]
|
||||
lines.append(
|
||||
BudgetLine(
|
||||
category=name,
|
||||
budget=Money(cents=row["amount_cents"]),
|
||||
spent=spent.get(name, Money.zero()),
|
||||
)
|
||||
)
|
||||
budgeted = {line.category for line in lines}
|
||||
for name, cost in sorted(spent.items()):
|
||||
if name not in budgeted and cost.is_positive:
|
||||
lines.append(BudgetLine(category=name, budget=Money.zero(), spent=cost))
|
||||
return lines
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
[project]
|
||||
name = "ledgerly"
|
||||
version = "0.1.0"
|
||||
description = "A tiny personal finance tracker: transactions, budgets, monthly reports. Stdlib + SQLite only."
|
||||
requires-python = ">=3.10"
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0", "coverage>=7.4"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["ledgerly"]
|
||||
omit = ["ledgerly/__main__.py"]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 95
|
||||
show_missing = true
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
"""Tests for ledgerly.services.budgets (R2, R7)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgerly.errors import NotFoundError, ValidationError
|
||||
from ledgerly.money import Money
|
||||
from ledgerly.repository import SqliteRepository
|
||||
from ledgerly.services import BudgetService
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def service():
|
||||
repo = SqliteRepository(":memory:")
|
||||
yield BudgetService(repo)
|
||||
repo.close()
|
||||
|
||||
|
||||
def test_set_budget(service):
|
||||
budget = service.set_budget("Rent", Money(150000))
|
||||
assert budget.category == "Rent"
|
||||
assert budget.amount == Money(150000)
|
||||
assert service.list_budgets() == [budget]
|
||||
|
||||
|
||||
def test_set_budget_replaces(service):
|
||||
service.set_budget("Rent", Money(100))
|
||||
service.set_budget("Rent", Money(200))
|
||||
assert [b.amount for b in service.list_budgets()] == [Money(200)]
|
||||
|
||||
|
||||
def test_set_budget_rejects_zero(service):
|
||||
with pytest.raises(ValidationError, match="greater than zero"):
|
||||
service.set_budget("Rent", Money(0))
|
||||
|
||||
|
||||
def test_set_budget_unknown_category(service):
|
||||
with pytest.raises(NotFoundError):
|
||||
service.set_budget("Nope", Money(100))
|
||||
|
||||
|
||||
def test_set_budget_rejects_income_category(service):
|
||||
with pytest.raises(ValidationError, match="expense"):
|
||||
service.set_budget("Salary", Money(100))
|
||||
|
||||
|
||||
def test_delete_budget_missing_category(service):
|
||||
with pytest.raises(NotFoundError):
|
||||
service.delete_budget("Nope")
|
||||
|
||||
|
||||
def test_delete_budget_not_set(service):
|
||||
with pytest.raises(NotFoundError, match="no budget"):
|
||||
service.delete_budget("Rent")
|
||||
|
||||
|
||||
def test_delete_budget_ok(service):
|
||||
service.set_budget("Rent", Money(100))
|
||||
deleted = service.delete_budget("rent")
|
||||
assert deleted.category == "Rent"
|
||||
assert deleted.amount == Money(100)
|
||||
assert service.list_budgets() == []
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
"""End-to-end CLI tests: main() with a temp database, captured stdout/stderr."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgerly import __version__
|
||||
from ledgerly.cli import build_parser, default_db_path, main
|
||||
from ledgerly.models import DEFAULT_CATEGORIES
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
return str(tmp_path / "test.db")
|
||||
|
||||
|
||||
def run(db, *argv):
|
||||
return main(list(argv), db_path=db)
|
||||
|
||||
|
||||
# -- basics --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_version(db, capsys):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main(["--version"], db_path=db)
|
||||
assert exc.value.code == 0
|
||||
assert __version__ in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_missing_command(db):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main([], db_path=db)
|
||||
assert exc.value.code == 2
|
||||
|
||||
|
||||
def test_default_db_path_under_home():
|
||||
path = default_db_path()
|
||||
assert path.name == "ledgerly.db"
|
||||
assert ".ledgerly" in path.parts
|
||||
|
||||
|
||||
def test_parser_exposes_all_commands():
|
||||
parser = build_parser()
|
||||
assert parser.prog == "ledgerly"
|
||||
|
||||
|
||||
# -- add / list / delete ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_and_list(db, capsys):
|
||||
assert run(db, "add", "82.10", "--category", "Groceries",
|
||||
"--date", "2026-10-02", "--note", "weekly") == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "#1" in out and "$82.10" in out and "weekly" in out
|
||||
assert run(db, "list", "--month", "2026-10") == 0
|
||||
assert "Groceries" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_add_default_date_and_month(db, capsys):
|
||||
assert run(db, "add", "10.00", "--category", "Rent") == 0
|
||||
assert run(db, "list") == 0 # current month by default
|
||||
assert "Rent" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_add_invalid_amount(db):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
run(db, "add", "abc", "--category", "Rent")
|
||||
assert exc.value.code == 2
|
||||
|
||||
|
||||
def test_add_invalid_date(db):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
run(db, "add", "1.00", "--category", "Rent", "--date", "nope")
|
||||
assert exc.value.code == 2
|
||||
|
||||
|
||||
def test_add_unknown_category_domain_error(db, capsys):
|
||||
assert run(db, "add", "1.00", "--category", "Nope") == 1
|
||||
assert "unknown category" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_add_zero_amount(db, capsys):
|
||||
assert run(db, "add", "0", "--category", "Rent") == 1
|
||||
assert "greater than zero" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_list_all(db, capsys):
|
||||
run(db, "add", "5.00", "--category", "Rent", "--date", "2025-01-05")
|
||||
run(db, "add", "6.00", "--category", "Rent", "--date", "2026-12-01")
|
||||
assert run(db, "list", "--all") == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "2025-01-05" in out and "2026-12-01" in out
|
||||
|
||||
|
||||
def test_list_no_transactions(db, capsys):
|
||||
assert run(db, "list", "--month", "2001-01") == 0
|
||||
assert "no transactions" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_list_by_category(db, capsys):
|
||||
run(db, "add", "5.00", "--category", "Rent", "--date", "2026-10-05")
|
||||
run(db, "add", "6.00", "--category", "Groceries", "--date", "2026-10-06")
|
||||
capsys.readouterr() # discard add output; keep the list output clean
|
||||
assert run(db, "list", "--month", "2026-10", "--category", "rent") == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Rent" in out and "Groceries" not in out
|
||||
|
||||
|
||||
def test_delete(db, capsys):
|
||||
assert run(db, "add", "5.00", "--category", "Rent", "--date", "2026-01-05") == 0
|
||||
out = capsys.readouterr().out
|
||||
tx_id = int(out.split("#", 1)[1].split(":", 1)[0].strip())
|
||||
assert run(db, "delete", str(tx_id)) == 0
|
||||
assert "deleted" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_delete_unknown(db, capsys):
|
||||
assert run(db, "delete", "999") == 1
|
||||
assert "unknown transaction" in capsys.readouterr().err
|
||||
|
||||
|
||||
# -- categories ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_categories(db, capsys):
|
||||
assert run(db, "categories") == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Salary" in out and "Rent" in out
|
||||
|
||||
|
||||
def test_categories_empty(db, capsys):
|
||||
for name, _kind in DEFAULT_CATEGORIES:
|
||||
assert run(db, "category-del", name) == 0
|
||||
assert run(db, "categories") == 0
|
||||
assert "no categories" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_category_add_and_del(db, capsys):
|
||||
assert run(db, "category-add", "Pets", "--type", "expense") == 0
|
||||
assert "Pets" in capsys.readouterr().out
|
||||
assert run(db, "category-add", "pets", "--type", "income") == 1
|
||||
assert "already exists" in capsys.readouterr().err
|
||||
assert run(db, "category-del", "Pets") == 0
|
||||
assert "deleted category" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_category_add_bad_type(db):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
run(db, "category-add", "Pets", "--type", "asset")
|
||||
assert exc.value.code == 2
|
||||
|
||||
|
||||
def test_category_del_in_use(db, capsys):
|
||||
run(db, "add", "5.00", "--category", "Rent", "--date", "2026-01-05")
|
||||
assert run(db, "category-del", "Rent") == 1
|
||||
assert "transactions or a budget" in capsys.readouterr().err
|
||||
|
||||
|
||||
# -- budgets -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_budget_set_and_del(db, capsys):
|
||||
assert run(db, "budget-set", "Rent", "1500") == 0
|
||||
assert "$1,500.00" in capsys.readouterr().out
|
||||
assert run(db, "budget-set", "rent", "2000") == 0 # replace
|
||||
assert "$2,000.00" in capsys.readouterr().out
|
||||
assert run(db, "budget-del", "Rent") == 0
|
||||
assert "removed budget" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_budget_set_income_category(db, capsys):
|
||||
assert run(db, "budget-set", "Salary", "100") == 1
|
||||
assert "expense" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_budget_set_invalid_amount(db):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
run(db, "budget-set", "Rent", "not-money")
|
||||
assert exc.value.code == 2
|
||||
|
||||
|
||||
def test_budget_del_not_set(db, capsys):
|
||||
assert run(db, "budget-del", "Rent") == 1
|
||||
assert "no budget" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_budget_show_with_overage(db, capsys):
|
||||
run(db, "budget-set", "Rent", "1500")
|
||||
run(db, "add", "1600.00", "--category", "Rent", "--date", "2026-10-05")
|
||||
assert run(db, "budget-show", "--month", "2026-10") == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Rent" in out and "OVER" in out and "budgets for 2026-10" in out
|
||||
|
||||
|
||||
def test_budget_show_flags_unbudgeted_spending(db, capsys):
|
||||
run(db, "add", "40.00", "--category", "Groceries", "--date", "2026-10-02")
|
||||
assert run(db, "budget-show", "--month", "2026-10") == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Groceries" in out and "OVER" in out
|
||||
|
||||
|
||||
def test_budget_show_no_budgets_no_spending(db, capsys):
|
||||
assert run(db, "budget-show", "--month", "2026-10") == 0
|
||||
assert "no budgets set" in capsys.readouterr().out
|
||||
|
||||
|
||||
# -- report ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_report(db, capsys):
|
||||
run(db, "add", "3000", "--category", "Salary", "--date", "2026-10-01")
|
||||
run(db, "add", "1200", "--category", "Rent", "--date", "2026-10-02")
|
||||
run(db, "add", "250", "--category", "Groceries", "--date", "2026-10-03")
|
||||
assert run(db, "report", "--month", "2026-10") == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "report for 2026-10" in out
|
||||
assert "income" in out and "$3,000.00" in out
|
||||
assert "expenses" in out and "$1,450.00" in out
|
||||
assert "net" in out and "$1,550.00" in out
|
||||
assert "Salary" in out and "Groceries" in out
|
||||
|
||||
|
||||
def test_report_empty(db, capsys):
|
||||
assert run(db, "report", "--month", "2001-01") == 0
|
||||
assert "no transactions" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_report_default_month(db, capsys):
|
||||
assert run(db, "report") == 0
|
||||
assert "report for" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_invalid_month(db):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
run(db, "report", "--month", "2026-99")
|
||||
assert exc.value.code == 2
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
"""Tests for ledgerly.services.ledger (R5-R10, R12)."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgerly.errors import ConflictError, NotFoundError, ValidationError
|
||||
from ledgerly.models import EXPENSE, INCOME, Month
|
||||
from ledgerly.money import Money
|
||||
from ledgerly.repository import SqliteRepository
|
||||
from ledgerly.services import LedgerService
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def service():
|
||||
repo = SqliteRepository(":memory:")
|
||||
yield LedgerService(repo)
|
||||
repo.close()
|
||||
|
||||
|
||||
# -- commands ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_category(service):
|
||||
cat = service.add_category("Pets", EXPENSE)
|
||||
assert cat.name == "Pets"
|
||||
assert cat.kind == EXPENSE
|
||||
assert "Pets" in [c.name for c in service.list_categories()]
|
||||
|
||||
|
||||
def test_add_category_duplicate(service):
|
||||
service.add_category("Pets", EXPENSE)
|
||||
with pytest.raises(ConflictError):
|
||||
service.add_category("pets", EXPENSE)
|
||||
|
||||
|
||||
def test_add_category_invalid_name(service):
|
||||
with pytest.raises(ValidationError):
|
||||
service.add_category(" ", EXPENSE)
|
||||
|
||||
|
||||
def test_add_category_invalid_kind(service):
|
||||
with pytest.raises(ValidationError):
|
||||
service.add_category("Pets", "asset")
|
||||
|
||||
|
||||
def test_delete_category_missing(service):
|
||||
with pytest.raises(NotFoundError):
|
||||
service.delete_category("Nope")
|
||||
|
||||
|
||||
def test_delete_category_in_use(service):
|
||||
service.add_transaction(Money(100), "Rent", date(2026, 10, 1))
|
||||
with pytest.raises(ConflictError, match="transactions or a budget"):
|
||||
service.delete_category("Rent")
|
||||
|
||||
|
||||
def test_delete_category_ok(service):
|
||||
service.delete_category("Rent")
|
||||
assert all(c.name != "Rent" for c in service.list_categories())
|
||||
|
||||
|
||||
def test_add_transaction_ok(service):
|
||||
tx = service.add_transaction(Money(8210), "Groceries", date(2026, 10, 2), " weekly ")
|
||||
assert tx.id >= 1
|
||||
assert tx.category == "Groceries"
|
||||
assert tx.kind == EXPENSE
|
||||
assert tx.note == "weekly"
|
||||
|
||||
|
||||
def test_add_transaction_rejects_zero(service):
|
||||
with pytest.raises(ValidationError, match="greater than zero"):
|
||||
service.add_transaction(Money(0), "Rent", date(2026, 10, 1))
|
||||
|
||||
|
||||
def test_add_transaction_unknown_category(service):
|
||||
with pytest.raises(NotFoundError):
|
||||
service.add_transaction(Money(10), "Nope", date(2026, 10, 1))
|
||||
|
||||
|
||||
def test_add_transaction_long_note(service):
|
||||
with pytest.raises(ValidationError, match="200"):
|
||||
service.add_transaction(Money(10), "Rent", date(2026, 10, 1), "x" * 201)
|
||||
|
||||
|
||||
def test_delete_transaction_missing(service):
|
||||
with pytest.raises(NotFoundError):
|
||||
service.delete_transaction(9999)
|
||||
|
||||
|
||||
def test_delete_transaction_ok(service):
|
||||
tx = service.add_transaction(Money(100), "Rent", date(2026, 10, 1))
|
||||
service.delete_transaction(tx.id)
|
||||
assert service.list_transactions(None, None) == []
|
||||
|
||||
|
||||
# -- queries -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_transactions_filter_by_month(service):
|
||||
service.add_transaction(Money(100), "Rent", date(2026, 10, 1))
|
||||
service.add_transaction(Money(200), "Rent", date(2026, 11, 1))
|
||||
rows = service.list_transactions(*Month(2026, 10).range)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].amount == Money(100)
|
||||
|
||||
|
||||
def test_list_transactions_filter_by_category(service):
|
||||
service.add_transaction(Money(100), "Rent", date(2026, 10, 1))
|
||||
service.add_transaction(Money(200), "Groceries", date(2026, 10, 2))
|
||||
rows = service.list_transactions(None, None, "rent")
|
||||
assert len(rows) == 1
|
||||
assert rows[0].category == "Rent"
|
||||
assert rows[0].kind == EXPENSE
|
||||
|
||||
|
||||
def test_list_transactions_unknown_category(service):
|
||||
with pytest.raises(NotFoundError):
|
||||
service.list_transactions(None, None, "Nope")
|
||||
|
||||
|
||||
def test_income_transaction_allowed(service):
|
||||
tx = service.add_transaction(Money(300000), "Salary", date(2026, 10, 1))
|
||||
assert tx.kind == INCOME
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
"""Tests for ledgerly.models (sanitizers, Month, read models)."""
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgerly.errors import ValidationError
|
||||
from ledgerly.models import (
|
||||
BudgetLine,
|
||||
Category,
|
||||
Month,
|
||||
MonthSummary,
|
||||
clean_name,
|
||||
clean_note,
|
||||
parse_date,
|
||||
validate_kind,
|
||||
)
|
||||
from ledgerly.money import Money
|
||||
|
||||
|
||||
def test_clean_name_ok():
|
||||
assert clean_name(" Rent ") == "Rent"
|
||||
|
||||
|
||||
def test_clean_name_rejects_blank():
|
||||
with pytest.raises(ValidationError):
|
||||
clean_name(" ")
|
||||
with pytest.raises(ValidationError):
|
||||
clean_name(None)
|
||||
|
||||
|
||||
def test_clean_name_rejects_long():
|
||||
with pytest.raises(ValidationError, match="50"):
|
||||
clean_name("x" * 51)
|
||||
|
||||
|
||||
def test_clean_note():
|
||||
assert clean_note(None) is None
|
||||
assert clean_note(" ") is None
|
||||
assert clean_note("a note") == "a note"
|
||||
|
||||
|
||||
def test_clean_note_rejects_long():
|
||||
with pytest.raises(ValidationError, match="200"):
|
||||
clean_note("n" * 201)
|
||||
|
||||
|
||||
def test_parse_date_ok():
|
||||
assert parse_date("2026-10-26") == date(2026, 10, 26)
|
||||
|
||||
|
||||
def test_parse_date_rejects_bad():
|
||||
for bad in ("2026-13-01", "2026-02-30", "25-10-01", "not-a-date", 123):
|
||||
with pytest.raises(ValidationError):
|
||||
parse_date(bad)
|
||||
|
||||
|
||||
def test_parse_date_rejects_out_of_range():
|
||||
with pytest.raises(ValidationError):
|
||||
parse_date("1999-12-31")
|
||||
with pytest.raises(ValidationError):
|
||||
parse_date("2101-01-01")
|
||||
|
||||
|
||||
def test_validate_kind():
|
||||
assert validate_kind("income") == "income"
|
||||
with pytest.raises(ValidationError):
|
||||
validate_kind("asset")
|
||||
|
||||
|
||||
def test_month_from_str_ok():
|
||||
month = Month.from_str("2026-10")
|
||||
assert (month.year, month.month) == (2026, 10)
|
||||
assert month.label == "2026-10"
|
||||
assert month.first_day == date(2026, 10, 1)
|
||||
assert month.last_day == date(2026, 10, 31)
|
||||
|
||||
|
||||
def test_month_from_str_rejects_bad():
|
||||
for bad in ("2026", "26-10", "2026-13", "2026-", "2026-1-3", "garbage", 2026):
|
||||
with pytest.raises(ValidationError):
|
||||
Month.from_str(bad)
|
||||
|
||||
|
||||
def test_month_constructor_validation():
|
||||
with pytest.raises(ValidationError):
|
||||
Month(2026, 13)
|
||||
with pytest.raises(ValidationError):
|
||||
Month(1999, 5)
|
||||
|
||||
|
||||
def test_month_range_leap_year():
|
||||
month = Month(year=2024, month=2)
|
||||
assert month.range == ("2024-02-01", "2024-02-29")
|
||||
|
||||
|
||||
def test_month_now_is_sane():
|
||||
month = Month.now()
|
||||
assert 1 <= month.month <= 12
|
||||
|
||||
|
||||
def test_month_summary_net():
|
||||
summary = MonthSummary(month=Month.now(), income=Money(3000), expenses=Money(3100))
|
||||
assert summary.net == Money(-100)
|
||||
|
||||
|
||||
def test_budget_line_over_and_under():
|
||||
over = BudgetLine(category="Rent", budget=Money(1000), spent=Money(1200))
|
||||
assert over.is_over
|
||||
assert over.remaining == Money(-200)
|
||||
under = BudgetLine(category="Rent", budget=Money(1000), spent=Money(800))
|
||||
assert not under.is_over
|
||||
assert under.remaining == Money(200)
|
||||
|
||||
|
||||
def test_entities_are_frozen():
|
||||
cat = Category(name="Rent", kind="expense")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
cat.name = "Other"
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
"""Tests for ledgerly.money (R3, Money value semantics)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgerly.money import InvalidMoney, Money
|
||||
|
||||
|
||||
def test_parse_integer_string():
|
||||
assert Money.parse("42") == Money(4200)
|
||||
|
||||
|
||||
def test_parse_two_decimals():
|
||||
assert Money.parse("12.34") == Money(1234)
|
||||
|
||||
|
||||
def test_parse_rounds_half_up():
|
||||
assert Money.parse("12.345") == Money(1235)
|
||||
assert Money.parse("12.344") == Money(1234)
|
||||
|
||||
|
||||
def test_parse_negative():
|
||||
assert Money.parse("-1.50") == Money(-150)
|
||||
|
||||
|
||||
def test_parse_accepts_numbers():
|
||||
assert Money.parse(3) == Money(300)
|
||||
assert Money.parse(2.5) == Money(250)
|
||||
|
||||
|
||||
def test_parse_rejects_garbage():
|
||||
for bad in ("abc", "", None):
|
||||
with pytest.raises(InvalidMoney):
|
||||
Money.parse(bad)
|
||||
|
||||
|
||||
def test_parse_rejects_non_finite():
|
||||
for bad in ("inf", "-inf", "nan"):
|
||||
with pytest.raises(InvalidMoney):
|
||||
Money.parse(bad)
|
||||
|
||||
|
||||
def test_cents_must_be_int():
|
||||
for bad in ("5", 1.5, True):
|
||||
with pytest.raises(TypeError):
|
||||
Money(bad)
|
||||
|
||||
|
||||
def test_predicates():
|
||||
assert Money(0).is_zero
|
||||
assert not Money(1).is_zero
|
||||
assert Money(1).is_positive
|
||||
assert not Money(0).is_positive
|
||||
assert not Money(-1).is_positive
|
||||
|
||||
|
||||
def test_zero_classmethod():
|
||||
assert Money.zero() == Money(0)
|
||||
|
||||
|
||||
def test_arithmetic():
|
||||
a, b = Money(100), Money(25)
|
||||
assert a + b == Money(125)
|
||||
assert a - b == Money(75)
|
||||
assert Money(10) - Money(20) == Money(-10)
|
||||
|
||||
|
||||
def test_arithmetic_type_errors():
|
||||
with pytest.raises(TypeError):
|
||||
Money(1) + 1
|
||||
with pytest.raises(TypeError):
|
||||
Money(1) - "1"
|
||||
|
||||
|
||||
def test_ordering_and_hash():
|
||||
assert Money(1) < Money(2)
|
||||
assert Money(2) <= Money(2)
|
||||
assert Money(2) == Money(2)
|
||||
assert hash(Money(2)) == hash(Money(2))
|
||||
|
||||
|
||||
def test_not_equal_to_other_types():
|
||||
assert Money(1) != 1
|
||||
assert Money(1) != "1"
|
||||
|
||||
|
||||
def test_str_formatting():
|
||||
assert str(Money(0)) == "$0.00"
|
||||
assert str(Money(123456)) == "$1,234.56"
|
||||
assert str(Money(-100)) == "-$1.00"
|
||||
|
||||
|
||||
def test_format_supports_alignment():
|
||||
assert format(Money(150000), "<12") == "$1,500.00 "
|
||||
assert format(Money(150000), "") == "$1,500.00"
|
||||
|
||||
|
||||
def test_repr():
|
||||
assert repr(Money(42)) == "Money(42)"
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
"""Tests for ledgerly.services.reports (R11)."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgerly.models import Month
|
||||
from ledgerly.money import Money
|
||||
from ledgerly.repository import SqliteRepository
|
||||
from ledgerly.services import BudgetService, LedgerService, ReportingService
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def services():
|
||||
repo = SqliteRepository(":memory:")
|
||||
yield LedgerService(repo), BudgetService(repo), ReportingService(repo)
|
||||
repo.close()
|
||||
|
||||
|
||||
def test_monthly_summary_empty(services):
|
||||
_ledger, _budgets, reports = services
|
||||
summary = reports.monthly_summary(Month(2026, 10))
|
||||
assert summary.income == Money(0)
|
||||
assert summary.expenses == Money(0)
|
||||
assert summary.net == Money(0)
|
||||
|
||||
|
||||
def test_monthly_summary_with_data(services):
|
||||
ledger, _budgets, reports = services
|
||||
ledger.add_transaction(Money(300000), "Salary", date(2026, 10, 1))
|
||||
ledger.add_transaction(Money(50000), "Groceries", date(2026, 10, 5))
|
||||
ledger.add_transaction(Money(1), "Rent", date(2026, 9, 5)) # out of month
|
||||
summary = reports.monthly_summary(Month(2026, 10))
|
||||
assert summary.income == Money(300000)
|
||||
assert summary.expenses == Money(50000)
|
||||
assert summary.net == Money(250000)
|
||||
|
||||
|
||||
def test_category_totals_ordering(services):
|
||||
ledger, _budgets, reports = services
|
||||
ledger.add_transaction(Money(300000), "Salary", date(2026, 10, 1))
|
||||
ledger.add_transaction(Money(50000), "Groceries", date(2026, 10, 2))
|
||||
ledger.add_transaction(Money(90000), "Rent", date(2026, 10, 3))
|
||||
totals = reports.category_totals(Month(2026, 10))
|
||||
assert [(t.category, t.total) for t in totals] == [
|
||||
("Salary", Money(300000)),
|
||||
("Rent", Money(90000)),
|
||||
("Groceries", Money(50000)),
|
||||
]
|
||||
|
||||
|
||||
def test_budget_status_budgeted_and_unbudgeted(services):
|
||||
ledger, budgets, reports = services
|
||||
budgets.set_budget("Rent", Money(150000))
|
||||
ledger.add_transaction(Money(160000), "Rent", date(2026, 10, 1)) # over
|
||||
ledger.add_transaction(Money(40000), "Groceries", date(2026, 10, 2)) # unbudgeted
|
||||
lines = reports.budget_status(Month(2026, 10))
|
||||
by_name = {line.category: line for line in lines}
|
||||
assert by_name["Rent"].is_over
|
||||
assert by_name["Rent"].remaining == Money(-10000)
|
||||
assert by_name["Groceries"].budget == Money(0)
|
||||
assert by_name["Groceries"].is_over
|
||||
|
||||
|
||||
def test_budget_status_within_budget(services):
|
||||
ledger, budgets, reports = services
|
||||
budgets.set_budget("Rent", Money(150000))
|
||||
ledger.add_transaction(Money(100000), "Rent", date(2026, 10, 1))
|
||||
line = reports.budget_status(Month(2026, 10))[0]
|
||||
assert not line.is_over
|
||||
assert line.remaining == Money(50000)
|
||||
|
||||
|
||||
def test_budget_status_empty(services):
|
||||
_ledger, _budgets, reports = services
|
||||
assert reports.budget_status(Month(2026, 10)) == []
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
"""Tests for ledgerly.repository (schema, seeds, CRUD, reporting queries)."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgerly.errors import ConflictError
|
||||
from ledgerly.models import DEFAULT_CATEGORIES, EXPENSE, INCOME
|
||||
from ledgerly.repository import Repository, SqliteRepository
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repo():
|
||||
r = SqliteRepository(":memory:")
|
||||
yield r
|
||||
r.close()
|
||||
|
||||
|
||||
def test_implements_protocol():
|
||||
r = SqliteRepository(":memory:")
|
||||
assert isinstance(r, Repository)
|
||||
r.close()
|
||||
|
||||
|
||||
def test_seeds_default_categories(repo):
|
||||
cats = repo.list_categories()
|
||||
names = {name for name, _ in cats}
|
||||
assert "Salary" in names
|
||||
assert "Rent" in names
|
||||
assert len(cats) == len(DEFAULT_CATEGORIES)
|
||||
|
||||
|
||||
def test_seed_idempotent(tmp_path):
|
||||
path = tmp_path / "sub" / "t.db"
|
||||
first = SqliteRepository(path)
|
||||
count = len(first.list_categories())
|
||||
first.close()
|
||||
second = SqliteRepository(path)
|
||||
assert len(second.list_categories()) == count
|
||||
second.close()
|
||||
|
||||
|
||||
def test_deleted_categories_do_not_resurrect(tmp_path):
|
||||
path = tmp_path / "t.db"
|
||||
first = SqliteRepository(path)
|
||||
for name, _kind in first.list_categories():
|
||||
first.delete_category(first.find_category(name)[0])
|
||||
first.close()
|
||||
second = SqliteRepository(path)
|
||||
assert second.list_categories() == []
|
||||
second.close()
|
||||
|
||||
|
||||
def test_create_category_duplicate_raises(repo):
|
||||
with pytest.raises(ConflictError):
|
||||
repo.create_category("Groceries", EXPENSE)
|
||||
|
||||
|
||||
def test_find_category_case_insensitive(repo):
|
||||
_id, name, kind = repo.find_category("grocErIes")
|
||||
assert name == "Groceries"
|
||||
assert kind == EXPENSE
|
||||
|
||||
|
||||
def test_find_category_missing(repo):
|
||||
assert repo.find_category("Nope") is None
|
||||
|
||||
|
||||
def test_list_categories_income_first(repo):
|
||||
cats = repo.list_categories()
|
||||
kinds = [kind for _name, kind in cats]
|
||||
assert kinds.index(INCOME) < kinds.index(EXPENSE)
|
||||
|
||||
|
||||
def test_category_in_use_transitions(repo):
|
||||
rent_id = repo.find_category("Rent")[0]
|
||||
assert not repo.category_in_use(rent_id)
|
||||
tx_id = repo.insert_transaction(100, rent_id, "2026-10-01", None)
|
||||
assert repo.category_in_use(rent_id)
|
||||
repo.delete_transaction(tx_id)
|
||||
assert not repo.category_in_use(rent_id)
|
||||
|
||||
|
||||
def test_category_in_use_via_budget(repo):
|
||||
rent_id = repo.find_category("Rent")[0]
|
||||
repo.set_budget(rent_id, 1000)
|
||||
assert repo.category_in_use(rent_id)
|
||||
|
||||
|
||||
def test_delete_category(repo):
|
||||
cat_id = repo.find_category("Rent")[0]
|
||||
repo.delete_category(cat_id)
|
||||
assert repo.find_category("Rent") is None
|
||||
|
||||
|
||||
def test_insert_and_list_transactions(repo):
|
||||
cat_id = repo.find_category("Groceries")[0]
|
||||
repo.insert_transaction(8210, cat_id, "2026-10-02", "weekly shop")
|
||||
repo.insert_transaction(4500, cat_id, "2026-10-20", None)
|
||||
rows = repo.list_transactions(None, None, None)
|
||||
assert rows[0]["date"] == "2026-10-20" # newest first
|
||||
assert rows[0]["category"] == "Groceries"
|
||||
assert rows[0]["note"] is None
|
||||
|
||||
|
||||
def test_list_transactions_filters(repo):
|
||||
grocery_id = repo.find_category("Groceries")[0]
|
||||
salary_id = repo.find_category("Salary")[0]
|
||||
repo.insert_transaction(8210, grocery_id, "2026-10-02", None)
|
||||
repo.insert_transaction(4500, salary_id, "2026-11-01", None)
|
||||
assert len(repo.list_transactions("2026-10-01", "2026-10-31", None)) == 1
|
||||
assert len(repo.list_transactions(None, None, grocery_id)) == 1
|
||||
|
||||
|
||||
def test_transaction_exists_and_delete(repo):
|
||||
cat_id = repo.find_category("Rent")[0]
|
||||
tx_id = repo.insert_transaction(150000, cat_id, "2026-10-01", None)
|
||||
assert repo.transaction_exists(tx_id)
|
||||
repo.delete_transaction(tx_id)
|
||||
assert not repo.transaction_exists(tx_id)
|
||||
|
||||
|
||||
def test_insert_enforces_positive_amount(repo):
|
||||
cat_id = repo.find_category("Rent")[0]
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
repo.insert_transaction(0, cat_id, "2026-10-01", None)
|
||||
|
||||
|
||||
def test_insert_enforces_foreign_key(repo):
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
repo.insert_transaction(100, 99999, "2026-10-01", None)
|
||||
|
||||
|
||||
def test_set_budget_upsert(repo):
|
||||
cat_id = repo.find_category("Rent")[0]
|
||||
repo.set_budget(cat_id, 100000)
|
||||
assert repo.get_budget(cat_id) == 100000
|
||||
repo.set_budget(cat_id, 120000)
|
||||
assert repo.get_budget(cat_id) == 120000
|
||||
assert len(repo.list_budgets()) == 1
|
||||
|
||||
|
||||
def test_delete_budget(repo):
|
||||
cat_id = repo.find_category("Rent")[0]
|
||||
assert not repo.delete_budget(cat_id)
|
||||
repo.set_budget(cat_id, 1000)
|
||||
assert repo.delete_budget(cat_id)
|
||||
assert repo.get_budget(cat_id) is None
|
||||
|
||||
|
||||
def test_list_budgets_shape(repo):
|
||||
repo.set_budget(repo.find_category("Rent")[0], 12345)
|
||||
row = repo.list_budgets()[0]
|
||||
assert row["category"] == "Rent"
|
||||
assert row["kind"] == EXPENSE
|
||||
assert row["amount_cents"] == 12345
|
||||
|
||||
|
||||
def test_income_expense_totals_empty(repo):
|
||||
assert repo.income_expense_totals("2026-10-01", "2026-10-31") == (0, 0)
|
||||
|
||||
|
||||
def test_income_expense_totals(repo):
|
||||
salary_id = repo.find_category("Salary")[0]
|
||||
grocery_id = repo.find_category("Groceries")[0]
|
||||
repo.insert_transaction(300000, salary_id, "2026-10-01", None)
|
||||
repo.insert_transaction(50000, grocery_id, "2026-10-05", None)
|
||||
repo.insert_transaction(7777, grocery_id, "2026-09-05", None) # out of range
|
||||
assert repo.income_expense_totals("2026-10-01", "2026-10-31") == (300000, 50000)
|
||||
|
||||
|
||||
def test_category_totals_ordering(repo):
|
||||
salary_id = repo.find_category("Salary")[0]
|
||||
grocery_id = repo.find_category("Groceries")[0]
|
||||
rent_id = repo.find_category("Rent")[0]
|
||||
repo.insert_transaction(300000, salary_id, "2026-10-01", None)
|
||||
repo.insert_transaction(50000, grocery_id, "2026-10-05", None)
|
||||
repo.insert_transaction(99999, rent_id, "2026-10-03", None)
|
||||
rows = repo.category_totals("2026-10-01", "2026-10-31")
|
||||
assert [r["category"] for r in rows] == ["Salary", "Rent", "Groceries"]
|
||||
|
||||
|
||||
def test_context_manager_closes(tmp_path):
|
||||
path = tmp_path / "t.db"
|
||||
with SqliteRepository(path) as repo:
|
||||
assert repo is not None
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
repo.list_categories()
|
||||
Loading…
Reference in a new issue