From 5b41e80ae1ea92b52b6a92873facf5b47229ad00 Mon Sep 17 00:00:00 2001 From: Funky Waddle Date: Tue, 27 Oct 2026 11:04:50 -0500 Subject: [PATCH] Initial commit --- .ai/guidelines/guidelines.md | 20 ++ .coverage | Bin 0 -> 53248 bytes README.md | 47 ++++ SPECS.md | 188 +++++++++++++ ledgerly/__init__.py | 5 + ledgerly/__main__.py | 8 + ledgerly/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 311 bytes ledgerly/__pycache__/__main__.cpython-313.pyc | Bin 0 -> 385 bytes ledgerly/__pycache__/cli.cpython-313.pyc | Bin 0 -> 13505 bytes ledgerly/__pycache__/db.cpython-313.pyc | Bin 0 -> 3067 bytes ledgerly/__pycache__/errors.cpython-313.pyc | Bin 0 -> 1442 bytes ledgerly/__pycache__/models.cpython-313.pyc | Bin 0 -> 9565 bytes ledgerly/__pycache__/money.cpython-313.pyc | Bin 0 -> 5699 bytes .../__pycache__/repository.cpython-313.pyc | Bin 0 -> 12595 bytes ledgerly/cli.py | 252 ++++++++++++++++++ ledgerly/db.py | 74 +++++ ledgerly/errors.py | 24 ++ ledgerly/models.py | 211 +++++++++++++++ ledgerly/money.py | 101 +++++++ ledgerly/repository.py | 222 +++++++++++++++ ledgerly/services/__init__.py | 7 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 428 bytes .../__pycache__/budgets.cpython-313.pyc | Bin 0 -> 3166 bytes .../__pycache__/ledger.cpython-313.pyc | Bin 0 -> 5315 bytes .../__pycache__/reports.cpython-313.pyc | Bin 0 -> 3571 bytes ledgerly/services/budgets.py | 56 ++++ ledgerly/services/ledger.py | 102 +++++++ ledgerly/services/reports.py | 63 +++++ pyproject.toml | 20 ++ tests/test_budgets_service.py | 61 +++++ tests/test_cli.py | 235 ++++++++++++++++ tests/test_ledger_service.py | 124 +++++++++ tests/test_models.py | 120 +++++++++ tests/test_money.py | 98 +++++++ tests/test_reports_service.py | 76 ++++++ tests/test_repository.py | 188 +++++++++++++ 36 files changed, 2302 insertions(+) create mode 100644 .ai/guidelines/guidelines.md create mode 100644 .coverage create mode 100644 README.md create mode 100644 SPECS.md create mode 100644 ledgerly/__init__.py create mode 100644 ledgerly/__main__.py create mode 100644 ledgerly/__pycache__/__init__.cpython-313.pyc create mode 100644 ledgerly/__pycache__/__main__.cpython-313.pyc create mode 100644 ledgerly/__pycache__/cli.cpython-313.pyc create mode 100644 ledgerly/__pycache__/db.cpython-313.pyc create mode 100644 ledgerly/__pycache__/errors.cpython-313.pyc create mode 100644 ledgerly/__pycache__/models.cpython-313.pyc create mode 100644 ledgerly/__pycache__/money.cpython-313.pyc create mode 100644 ledgerly/__pycache__/repository.cpython-313.pyc create mode 100644 ledgerly/cli.py create mode 100644 ledgerly/db.py create mode 100644 ledgerly/errors.py create mode 100644 ledgerly/models.py create mode 100644 ledgerly/money.py create mode 100644 ledgerly/repository.py create mode 100644 ledgerly/services/__init__.py create mode 100644 ledgerly/services/__pycache__/__init__.cpython-313.pyc create mode 100644 ledgerly/services/__pycache__/budgets.cpython-313.pyc create mode 100644 ledgerly/services/__pycache__/ledger.cpython-313.pyc create mode 100644 ledgerly/services/__pycache__/reports.cpython-313.pyc create mode 100644 ledgerly/services/budgets.py create mode 100644 ledgerly/services/ledger.py create mode 100644 ledgerly/services/reports.py create mode 100644 pyproject.toml create mode 100644 tests/test_budgets_service.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_ledger_service.py create mode 100644 tests/test_models.py create mode 100644 tests/test_money.py create mode 100644 tests/test_reports_service.py create mode 100644 tests/test_repository.py diff --git a/.ai/guidelines/guidelines.md b/.ai/guidelines/guidelines.md new file mode 100644 index 0000000..76a0353 --- /dev/null +++ b/.ai/guidelines/guidelines.md @@ -0,0 +1,20 @@ +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. \ No newline at end of file diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..5ae6da3456bbc599df8ddd5288f2dec2a8a7f79c GIT binary patch literal 53248 zcmeI4U2Ggz6~||GW_NaWJ$p+-SQ%41NHmVA?Zri?q%FiY_Cw-`enmWh#AbH)+MaZG z#-5pt<3O-BtrQ6f30^>zBKlUu15|xzD)kEx50z*o)Hf)FfDm<6QCC%4(tJ>J?tFN? ziCtCeNKKvpXm{_-oqNul^SkHVyLUh4f%^{G0XJt{uVDq|SEQRHS(e63Q<9_{Jq>y$ zn?^foazLfLVSlULoHTM`!C>E(dNMzj*!PTk*l^DoV^99&o`(LT{4X?1uhI!@AOHd& z00K8Qf!2LJTCulRK6Ns%Ds>)sR+W3vtMSeIr^XLYnTN+G4o#U+ow+w>(l$J7j+>sl zXwGxboU!ZNw4It=wF29jHG?^xv~K#mCMG(nqp40b90}#cUfHgZRKRCR#k^-XEN{tt zkS`5|4dSZ>{CE&mkP>dsI--TSHx?f>JwC%d?o_!SS+Uoy4dw1N< zT(JXxeMj#In)T`}LPwn6I&MUkbOwpYqI%tQXTp>b(q_~-6wMoRrj)?2JdtRo8#g3N zKh|gy#*z(YrS4WbV=asrpHqU+SdcWEX*yMrD!%Zc>K@~sHOoihv(d2daR(BG&ZwR# zPW5jUI_n`&9$nwfwR$sJv9C`)rH5e@_f}kJ9U+wT^JO`hgU!^f;TjVAM! z?NQ3o7#z(2rf68RsIgUXn?V@A;xG(PG556XjTE$x6ZX_RD-jTZg|Sbj%(b2yIFx7ctBqrld>q zi*_0f%c;flm6(Pjtu*$F&F-`=#4LHPMbOkZEMLk%F zC?u`o_d2;jvA~zvC5ax`KmY_l00ck)1V8`;KmY_l00ck)1a=+)MNY|@SpTQkKPC1S zydj1y*Mw`*3VNq&ZAO>3m9Z#!m{%eG?&GDPw(xR8>20N~|L|XPMq($jMOM@axC>Tq1 zNr8Kw>j?@IGHKAeFkwSqi0}XNh95lckI;OUiZnPxpf2CUr>+Ap2 zgxb=#x&Fuf|1kW400@8p2!H?xfB*=900@8p2!OzjCZNcQWQgzoWp-Jj2R0A@0T2KI z5C8!X009sH0T2KI5CDN4NI+2xJ^cSa`=Z1yuvPX7dx|~A9%esa4*L$9rfO^;00JNY z0w4eaAOHd&00JNY0w8e15V$q3%KD*v>f~>GzVRp<`R{wrHJ@qxe*fPuJo?VQl{0HE zE}vgn8(aIlk)vjx%T4_J%|DedesJMtoeuZtr%wFk{Y#IponLw4gSApmmg+v6eS`mN z?bmB>XEZu0Xiu9NDkm~;d`9}+|1R&p_{-&s(<`t2&~eT^zj*#<&-g2=1(v23X8P!E zVy>?3x}5ofN)?l8>Tlk`fB)w2ul{rP?bYYsT7CbO7uL=#p1Js~I|{2uZdRzt9wqhk zOU+mR(ZBfY3uhO;s;8*#)2Y-WkH~3R)%C)c!vFuXCna`?z02NYud#FN&+KLPNA?H0 zBjA%aOc%pZ5C8!X009sH0T2KI5C8!X009uV{s@TA3*>x0EONQ9(Dks$X2U|$!XlFi zi*!0HR5dIVB`i`Yl|BFnzyFtRy8c{*MIZnIAOHd&00JNY0w4eaAOHd&00P&JfcX9& z>;G%V6PiE(1V8`;KmY_l00ck)1V8`;K;U{LApZZ4_5bzQGq4E+KmY_l00ck)1V8`; OKmY_l00gcbf&T-D{JaYQ literal 0 HcmV?d00001 diff --git a/README.md b/README.md new file mode 100644 index 0000000..80b1ca5 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# 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/) +``` diff --git a/SPECS.md b/SPECS.md new file mode 100644 index 0000000..3a274f3 --- /dev/null +++ b/SPECS.md @@ -0,0 +1,188 @@ +# 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). diff --git a/ledgerly/__init__.py b/ledgerly/__init__.py new file mode 100644 index 0000000..63f6cc1 --- /dev/null +++ b/ledgerly/__init__.py @@ -0,0 +1,5 @@ +"""Ledgerly — a tiny personal finance tracker (transactions, budgets, reports).""" + +__version__ = "0.1.0" + +__all__ = ["__version__"] diff --git a/ledgerly/__main__.py b/ledgerly/__main__.py new file mode 100644 index 0000000..f35c56d --- /dev/null +++ b/ledgerly/__main__.py @@ -0,0 +1,8 @@ +"""Entry point for ``python -m ledgerly``.""" + +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ledgerly/__pycache__/__init__.cpython-313.pyc b/ledgerly/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c1ceb20b0af86cc9d8c49a926b41054ad0af85e0 GIT binary patch literal 311 zcmXv}%}T>S5Y8rQi=iGw!Q&ncNVa;Gg5pJxBEk&Snovp}!cfmi{hOGCAym^U-Uc!(izF_E2gJ`Q7Q{hHs zQ-wv%zm~6Da+m9xmkO=Xvfz_kOPwn2P^NQ*{FsQgGIhDp_JrS8H0y|fVixi{`taWRiQ@Mr*+-m_JGV!z!>}V?%KV>W!Q#CFXFj} HJ5=}uRgzo> literal 0 HcmV?d00001 diff --git a/ledgerly/__pycache__/__main__.cpython-313.pyc b/ledgerly/__pycache__/__main__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..43b15fcd9832fab7b1a03732c6d4d96f241c1987 GIT binary patch literal 385 zcmey&%ge<81ipn2vYLSOV-N=hSfGs05kSUNhG2#whG52ECU3?f#$e`P7H=jm<|1YV zh8UJ$Rx6;ALlAo6v?^GG@=&2t75sNp{hdDwYW}99??RR~2gli4V+-jEpxKbRRQtJ!KGmz|7rIb(vZ61`B(;N0UcqNO#y(7LgCk3@lPb HJV4a|;1Xg< literal 0 HcmV?d00001 diff --git a/ledgerly/__pycache__/cli.cpython-313.pyc b/ledgerly/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2a02320a5ebd9381f00f78a510ea29866c6f05ee GIT binary patch literal 13505 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 Ujao13`9=4CGVt>mCiIK{8?hhTd;kCd literal 0 HcmV?d00001 diff --git a/ledgerly/__pycache__/db.cpython-313.pyc b/ledgerly/__pycache__/db.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..715bafd85a5dd69a386445c73d5477d22064a8c7 GIT binary patch literal 3067 zcma)8&2JmW6`v)SA0|aPR2;`vir~RDEUu%HNoyCa?8IQGl}+_4 zcrDK2_%GJPs|l9CxrCamC9kGf3P_2lscQmDt7&#b&8kNx$xV@E)G;;VkFv{gDK|KW zrqye8)1}~aS7=`c-7=tSTNdrQrftEJ-gRxZ2?cdtuBgSn0ss8?`%>|9?wcfdS>3X1 zSLdQm6TiiI-Cg-y2o@)1<+*aF-qtGRwtTszRAp6)dr7r&MQ)V6RJe=xvO}q%8FWb> z^jxj0yL8!RCUx}daY8Z`MMiCCm*1|-P@RKjs}1t?s@hgzUla-%z-AiQRhzh(tiZfd zZIqRFpeDZqaeT&Uby$+pb^;$=UN~>PyTQ*yjvMLAC zt0t!9pfpBNsX@VCsWzc7Wm;XkPp76~if*h?%b`<}l#woEzA6Nl>6U{j=J^{9f=9#K zHw;Ga+XD-8M=jUc)8U!&;|e`JAoLczE(M{5S=`ik_M-jvbQO6{R^(F;23V{ zy+Kg%++deuG-?SPAl8H@2F~6i3wvuFay}eeLPt=AnRP<+1|bV59kh-RNhsXPp>{s} zR+5Jn@Y9NQV*7T~SY&5pE*{ODi(NoJ^cM)}#1!Po!%Y?Om-r#F;)2hod|eqkEJesE zvhS}IACVn@R<;xGrK8zSTzN4X@BgdG$Uh(`@zVZEG+H~$~8>~$@?VZ0RGpxuE>y<9kOy3Aa2SqjA^(MyFn@I;;omgh!9euX%E zW2}1ceo$IL0xm5LtkuoZ8nbU=W}MQjZBgy*O|4ul%?ESZcS}8rz-GP8lCfA^+w@)X zyR&G^`4x6U@^p-h9lgE&%ZtO=-BkAW)=ui=qx?(1Hhw$#Fn?w{e`Y6Nc$hD3=S#Pf zyV?9lr$1mH7XCE*$%}ui-alE~$rg9VChpen)c z9OM60;2NzF5BZR!0fr-F|E-%oxeUz=D5HH7T8wrnMX(_Q-;3IRw)B4 zB!m%GJ10Y|bOeRX3N*3) zQ3doCLeyMBD-?K1hf3Tm?QFYMPhPw50*+Q%z$W1B|mcXE%4HXD$Ym zmF(MC3r>&?)Wl;&Y0f$UE%507One9Zba?5Ds4?i#x7cgA#DV8<+ISon1mRy~<}-5Y lQ!@T`=D(SlVd}B?N=6(epI$sFyzsx%q|T>{zm`+ literal 0 HcmV?d00001 diff --git a/ledgerly/__pycache__/errors.cpython-313.pyc b/ledgerly/__pycache__/errors.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cee1732d4d5755a6132804bc657256ad662b619c GIT binary patch literal 1442 zcmbVMOK;Oa5MJlerYWI3gg|gvacU%RXeFc;AtW9}sst5n5T{DZ)}ACTjyIiML!A>R z&b{&664WkF*6@RE-JhIR0DQ36ayP_#ef_$C*)0sYLfdsZm|2 zz6sNq{c}p~r8+)YSx~AIp>=VTKw@ZlR;kbgPqP=?HoossROGN)`-rSZZW#w z>X|;YI%)DIYYousQk8A%iAg{`%GAU4){6pD%dPmNyRE#i)*obfZn@$7#;oEG;!9-( zFOZkxXQ~*-Ia(ky^w*O?>SSn04T*8mQYyBSk^Dl_*HI#o${`oGmTqq?-D&!HUK=>a zyK{;KkBjVa%-nEXymY$A_?bvn=*OeWWQP7r(m29i3MAK10;$bqe>sz&EeUmN<(Ax9 zTK+Fdj)`nI?i5Le&P0;V8F6%#%<$VL`eUdV>`vlA`6D0;y7MspU=)05Rn(kFG!t?dUe;KQd-Qr@Jz2zju7 zTR5uSFXaCKZ%`B-Kl_BtkFw`^KTB1w^6R{q-}K(k9?y8*>cRT4pvQ5I{r;5y{M6s{ O4xay}kLZ|X!Ttfuj%C{b literal 0 HcmV?d00001 diff --git a/ledgerly/__pycache__/models.cpython-313.pyc b/ledgerly/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d5a0ab2a54ca97102c18694075266815749ec046 GIT binary patch literal 9565 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 HP2~Ro7E#ac literal 0 HcmV?d00001 diff --git a/ledgerly/__pycache__/money.cpython-313.pyc b/ledgerly/__pycache__/money.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8adeea7bd1e86b085f5f1eef63d57bb614fe6343 GIT binary patch literal 5699 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 NAQb-a7YxD9`5%f?srCQ> literal 0 HcmV?d00001 diff --git a/ledgerly/__pycache__/repository.cpython-313.pyc b/ledgerly/__pycache__/repository.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b812737bf5135cd52df247d1215c131802a852c8 GIT binary patch literal 12595 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 literal 0 HcmV?d00001 diff --git a/ledgerly/cli.py b/ledgerly/cli.py new file mode 100644 index 0000000..7ef0b94 --- /dev/null +++ b/ledgerly/cli.py @@ -0,0 +1,252 @@ +"""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 diff --git a/ledgerly/db.py b/ledgerly/db.py new file mode 100644 index 0000000..4c44023 --- /dev/null +++ b/ledgerly/db.py @@ -0,0 +1,74 @@ +"""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() diff --git a/ledgerly/errors.py b/ledgerly/errors.py new file mode 100644 index 0000000..b47bea4 --- /dev/null +++ b/ledgerly/errors.py @@ -0,0 +1,24 @@ +"""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).""" diff --git a/ledgerly/models.py b/ledgerly/models.py new file mode 100644 index 0000000..eb44fe2 --- /dev/null +++ b/ledgerly/models.py @@ -0,0 +1,211 @@ +"""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 diff --git a/ledgerly/money.py b/ledgerly/money.py new file mode 100644 index 0000000..c3342ac --- /dev/null +++ b/ledgerly/money.py @@ -0,0 +1,101 @@ +"""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})" diff --git a/ledgerly/repository.py b/ledgerly/repository.py new file mode 100644 index 0000000..a513a97 --- /dev/null +++ b/ledgerly/repository.py @@ -0,0 +1,222 @@ +"""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() diff --git a/ledgerly/services/__init__.py b/ledgerly/services/__init__.py new file mode 100644 index 0000000..a62a360 --- /dev/null +++ b/ledgerly/services/__init__.py @@ -0,0 +1,7 @@ +"""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"] diff --git a/ledgerly/services/__pycache__/__init__.cpython-313.pyc b/ledgerly/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a09e76d68fbd41e1925a2fa9526f8f80abec4e79 GIT binary patch literal 428 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 MQ$lQuz`)i06GB=d>Hq)$ literal 0 HcmV?d00001 diff --git a/ledgerly/services/budgets.py b/ledgerly/services/budgets.py new file mode 100644 index 0000000..55aa384 --- /dev/null +++ b/ledgerly/services/budgets.py @@ -0,0 +1,56 @@ +"""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() + ] diff --git a/ledgerly/services/ledger.py b/ledgerly/services/ledger.py new file mode 100644 index 0000000..a492634 --- /dev/null +++ b/ledgerly/services/ledger.py @@ -0,0 +1,102 @@ +"""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 + ] diff --git a/ledgerly/services/reports.py b/ledgerly/services/reports.py new file mode 100644 index 0000000..14ef115 --- /dev/null +++ b/ledgerly/services/reports.py @@ -0,0 +1,63 @@ +"""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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c279fbb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[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 diff --git a/tests/test_budgets_service.py b/tests/test_budgets_service.py new file mode 100644 index 0000000..b73a3d6 --- /dev/null +++ b/tests/test_budgets_service.py @@ -0,0 +1,61 @@ +"""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() == [] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..73313a5 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,235 @@ +"""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 diff --git a/tests/test_ledger_service.py b/tests/test_ledger_service.py new file mode 100644 index 0000000..3a9557e --- /dev/null +++ b/tests/test_ledger_service.py @@ -0,0 +1,124 @@ +"""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 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..ee10542 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,120 @@ +"""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" diff --git a/tests/test_money.py b/tests/test_money.py new file mode 100644 index 0000000..8e2f27f --- /dev/null +++ b/tests/test_money.py @@ -0,0 +1,98 @@ +"""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)" diff --git a/tests/test_reports_service.py b/tests/test_reports_service.py new file mode 100644 index 0000000..8114646 --- /dev/null +++ b/tests/test_reports_service.py @@ -0,0 +1,76 @@ +"""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)) == [] diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..3b8f9f2 --- /dev/null +++ b/tests/test_repository.py @@ -0,0 +1,188 @@ +"""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()