keptwise 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
keptwise/__init__.py ADDED
File without changes
keptwise/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app(prog_name="keptwise")
keptwise/categories.py ADDED
@@ -0,0 +1,60 @@
1
+ """Merging and regrouping categories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+
7
+
8
+ def category_id(conn: sqlite3.Connection, name: str) -> int | None:
9
+ row = conn.execute("SELECT id FROM categories WHERE name = ?", (name,)).fetchone()
10
+ return row[0] if row else None
11
+
12
+
13
+ def merge(conn: sqlite3.Connection, source_id: int, target_id: int) -> int:
14
+ """Move everything from one category into another, then delete the source.
15
+
16
+ Transactions (including ones you categorised by hand) and rules all move across.
17
+ Returns how many transactions moved. Doesn't commit.
18
+ """
19
+ if source_id == target_id:
20
+ return 0
21
+ moved = conn.execute(
22
+ "UPDATE transactions SET category_id = ? WHERE category_id = ?", (target_id, source_id)
23
+ ).rowcount
24
+ conn.execute("UPDATE rules SET category_id = ? WHERE category_id = ?", (target_id, source_id))
25
+ conn.execute("DELETE FROM categories WHERE id = ?", (source_id,))
26
+ return moved
27
+
28
+
29
+ def merge_by_name(conn: sqlite3.Connection, sources: list[str], target: str) -> None:
30
+ """Merge ``sources`` into ``target``, creating ``target`` by renaming the first
31
+ source that exists. Missing names are skipped, so it's safe to run twice."""
32
+ present = [(n, category_id(conn, n)) for n in sources]
33
+ present = [(n, cid) for n, cid in present if cid is not None]
34
+ target_id = category_id(conn, target)
35
+ if target_id is None:
36
+ if not present:
37
+ return
38
+ name, target_id = present.pop(0)
39
+ conn.execute("UPDATE categories SET name = ? WHERE id = ?", (target, target_id))
40
+ for _, cid in present:
41
+ if cid != target_id:
42
+ merge(conn, cid, target_id)
43
+
44
+
45
+ # October 2026: fewer ad hoc categories.
46
+ SIMPLER_CATEGORIES: list[tuple[list[str], str]] = [
47
+ (["Eating & drinking out", "Coffee & snacks"], "Eating & drinking"),
48
+ (["Transport", "Car & charging"], "Transport"),
49
+ (["Shopping", "Health & beauty"], "Shopping"),
50
+ (["Going out & events", "Trips & stays"], "Going out & trips"),
51
+ (["Other bills & fees", "Charity"], "Other"),
52
+ ]
53
+
54
+
55
+ def simplify(conn: sqlite3.Connection) -> None:
56
+ """Database migration 3: merge the starter ad hoc categories into fewer groups
57
+ and treat cash-machine withdrawals as Cash."""
58
+ for sources, target in SIMPLER_CATEGORIES:
59
+ merge_by_name(conn, sources, target)
60
+ conn.execute("UPDATE categories SET bucket = 'cash' WHERE name = 'Cash machine'")
keptwise/cli.py ADDED
@@ -0,0 +1,251 @@
1
+ """Command line: ``keptwise --help``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import threading
7
+ import time
8
+ import urllib.request
9
+ import webbrowser
10
+ from pathlib import Path
11
+
12
+ import click
13
+ from click.core import ParameterSource
14
+
15
+ from . import db as storage
16
+ from . import reports
17
+ from .importer import ImportProblem, import_path
18
+ from .money import fmt
19
+ from .rules import categorise
20
+ from .seed import seed
21
+
22
+ DEFAULT_PORT = 8765 # 5000 clashes with AirPlay Receiver on macOS
23
+
24
+
25
+ def _connect(db_path: str | None, *, backup: bool = False):
26
+ if backup:
27
+ storage.backup(db_path)
28
+ conn = storage.connect(db_path)
29
+ seed(conn)
30
+ return conn
31
+
32
+
33
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
34
+ @click.option(
35
+ "--db",
36
+ "db_path",
37
+ envvar=["KEPTWISE_DB", "BOOKKEEPING_DB"],
38
+ type=click.Path(dir_okay=False),
39
+ help="Database file (default: data/keptwise.db in a source checkout, else your data folder).",
40
+ )
41
+ @click.pass_context
42
+ def app(ctx: click.Context, db_path: str | None) -> None:
43
+ """Import bank statements, track spending and plan monthly investments."""
44
+ ctx.obj = {"db": db_path}
45
+
46
+
47
+ @app.command()
48
+ @click.option("--port", default=DEFAULT_PORT, show_default=True)
49
+ @click.option("--host", default="127.0.0.1", show_default=True)
50
+ @click.option("--no-browser", is_flag=True, help="Don't open a browser tab.")
51
+ @click.option(
52
+ "--idle-timeout",
53
+ default=0,
54
+ show_default=True,
55
+ metavar="MINUTES",
56
+ help="Stop by itself after this long without use (0 = keep running).",
57
+ )
58
+ @click.option("--debug", is_flag=True, help="Auto-reload on code changes.")
59
+ @click.pass_context
60
+ def serve(
61
+ ctx: click.Context, port: int, host: str, no_browser: bool, idle_timeout: int, debug: bool
62
+ ) -> None:
63
+ """Start the app and open it in your browser."""
64
+ url = f"http://{host}:{port}/"
65
+ if is_running(url):
66
+ click.echo(f"Keptwise is already running at {url}")
67
+ if not no_browser:
68
+ webbrowser.open(url)
69
+ return
70
+
71
+ from .web import create_app
72
+
73
+ flask_app = create_app(ctx.obj["db"])
74
+ if not no_browser:
75
+ threading.Timer(1.0, lambda: webbrowser.open(url)).start()
76
+ if debug:
77
+ click.secho(f"Keptwise is running at {url} (Ctrl+C to stop)", fg="green")
78
+ flask_app.run(host=host, port=port, debug=True, use_reloader=True)
79
+ return
80
+
81
+ import logging
82
+
83
+ from werkzeug.serving import make_server
84
+
85
+ logging.getLogger("werkzeug").setLevel(logging.WARNING) # no per-request log lines
86
+ server = make_server(host, port, flask_app, threaded=True)
87
+ last_used = [time.monotonic()]
88
+
89
+ @flask_app.before_request
90
+ def _touch() -> None:
91
+ last_used[0] = time.monotonic()
92
+
93
+ def stop() -> None:
94
+ threading.Thread(target=server.shutdown, daemon=True).start()
95
+
96
+ flask_app.config["SHUTDOWN"] = stop
97
+ if idle_timeout > 0:
98
+
99
+ def watch() -> None:
100
+ while True:
101
+ time.sleep(15)
102
+ if time.monotonic() - last_used[0] > idle_timeout * 60:
103
+ server.shutdown()
104
+ return
105
+
106
+ threading.Thread(target=watch, daemon=True).start()
107
+
108
+ stops = f", stops after {idle_timeout} min unused" if idle_timeout else ""
109
+ click.secho(f"Keptwise is running at {url} (Ctrl+C to stop{stops})", fg="green")
110
+ with contextlib.suppress(KeyboardInterrupt):
111
+ server.serve_forever()
112
+ click.echo("Keptwise stopped.")
113
+
114
+
115
+ def is_running(url: str) -> bool:
116
+ try:
117
+ with urllib.request.urlopen(url + "healthz", timeout=1) as response:
118
+ return response.read() == b"ok"
119
+ except OSError:
120
+ return False
121
+
122
+
123
+ @app.command()
124
+ @click.option("--port", default=DEFAULT_PORT, show_default=True)
125
+ def stop(port: int) -> None:
126
+ """Stop an app started in the background."""
127
+ request = urllib.request.Request(f"http://127.0.0.1:{port}/shutdown", method="POST")
128
+ try:
129
+ urllib.request.urlopen(request, timeout=2).close()
130
+ except OSError:
131
+ click.echo("Keptwise isn't running.")
132
+ return
133
+ click.echo("Keptwise stopped.")
134
+
135
+
136
+ @app.command("import")
137
+ @click.argument("files", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False))
138
+ @click.pass_context
139
+ def import_command(ctx: click.Context, files: tuple[str, ...]) -> None:
140
+ """Import one or more bank CSV statements."""
141
+ conn = _connect(ctx.obj["db"], backup=True)
142
+ failed = False
143
+ for path in files:
144
+ try:
145
+ result = import_path(conn, path)
146
+ except ImportProblem as exc:
147
+ click.secho(f"{Path(path).name}: {exc}", fg="red", err=True)
148
+ failed = True
149
+ continue
150
+ click.echo(result.summary)
151
+ uncategorised = conn.execute(
152
+ "SELECT COUNT(*) FROM transactions WHERE category_id IS NULL"
153
+ ).fetchone()[0]
154
+ if uncategorised:
155
+ noun = "transaction has" if uncategorised == 1 else "transactions have"
156
+ click.secho(f"{uncategorised} {noun} no category yet.", fg="yellow")
157
+ if failed:
158
+ raise SystemExit(1)
159
+
160
+
161
+ @app.command()
162
+ @click.argument("month", required=False)
163
+ @click.pass_context
164
+ def summary(ctx: click.Context, month: str | None) -> None:
165
+ """Show a month's summary and investment plan (default: latest month)."""
166
+ conn = _connect(ctx.obj["db"])
167
+ months = reports.months(conn)
168
+ if not months:
169
+ raise click.ClickException("Nothing imported yet. Try: keptwise import FILE.csv")
170
+ month = month or months[-1]
171
+ if month not in months:
172
+ raise click.ClickException(f"No transactions for {month}. Months: {', '.join(months)}")
173
+ s = reports.summarise(conn, month)
174
+ click.secho(s.label, bold=True)
175
+ for key in ("regular", "cash", "adhoc", "investment"):
176
+ b = s.buckets[key]
177
+ click.echo(
178
+ f" {b.label:<12} in {fmt(b.money_in):>11} out {fmt(b.money_out):>11}"
179
+ f" net {fmt(b.net):>11}"
180
+ )
181
+ click.echo(f" {'Kept':<12} {fmt(s.surplus):>51}")
182
+ if s.surplus > 0:
183
+ plan = ", ".join(f"{fmt(a.amount)} into {a.name}" for a in s.allocations if a.share)
184
+ click.secho(f"In {s.next_label}: put {plan}.", fg="green")
185
+ else:
186
+ click.secho(f"Over by {fmt(-s.surplus)}: nothing to invest in {s.next_label}.", fg="red")
187
+ from .insights import month_flags
188
+
189
+ flags = month_flags(conn, month)
190
+ if flags:
191
+ click.echo("\nWorth a look:")
192
+ for flag in flags:
193
+ click.echo(f" • {flag.message}")
194
+ if s.uncategorised_count:
195
+ n = s.uncategorised_count
196
+ click.secho(
197
+ f"{n} transaction{'s' if n != 1 else ''} need{'s' if n == 1 else ''} a category.",
198
+ fg="yellow",
199
+ )
200
+
201
+
202
+ @app.command()
203
+ @click.pass_context
204
+ def recategorise(ctx: click.Context) -> None:
205
+ """Re-apply rules to every transaction you haven't categorised by hand."""
206
+ conn = _connect(ctx.obj["db"])
207
+ with conn:
208
+ changed = categorise(conn)
209
+ click.echo(f"{changed} transactions changed category.")
210
+
211
+
212
+ @app.command()
213
+ @click.option("--months", default=12, show_default=True, help="How many months to make up.")
214
+ @click.option("--port", default=DEFAULT_PORT + 1, show_default=True)
215
+ @click.option("--no-serve", is_flag=True, help="Only create the demo database.")
216
+ @click.option("--no-browser", is_flag=True, help="Don't open a browser tab.")
217
+ @click.pass_context
218
+ def demo(ctx: click.Context, months: int, port: int, no_serve: bool, no_browser: bool) -> None:
219
+ """Try the app with a made-up year of data (kept apart from your own).
220
+
221
+ Stop it with ``keptwise stop --port 8766``.
222
+ """
223
+ from . import demo as demo_data
224
+
225
+ # Only an explicit --db is honoured here: KEPTWISE_DB points at your real data.
226
+ explicit = ctx.parent.get_parameter_source("db_path") == ParameterSource.COMMANDLINE
227
+ if explicit:
228
+ path = Path(ctx.obj["db"])
229
+ else:
230
+ path = storage.default_db_path().parent / "demo.db"
231
+ for old in (path, path.with_name(path.name + "-journal")):
232
+ old.unlink(missing_ok=True) # always start the demo afresh
233
+ conn = _connect(str(path))
234
+ try:
235
+ added = demo_data.build(conn, months=months)
236
+ except ValueError as exc:
237
+ raise click.ClickException(f"{path} already has transactions. {exc}") from exc
238
+ finally:
239
+ conn.close()
240
+ click.echo(f"Made up {added} transactions over {months} months in {path}")
241
+ if not no_serve:
242
+ ctx.obj["db"] = str(path)
243
+ ctx.invoke(serve, port=port, no_browser=no_browser)
244
+
245
+
246
+ def main() -> None: # pragma: no cover
247
+ app()
248
+
249
+
250
+ if __name__ == "__main__": # pragma: no cover
251
+ main()
keptwise/dates.py ADDED
@@ -0,0 +1,11 @@
1
+ """Date formatting that works the same on every operating system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+
7
+
8
+ def strftime(value: date, fmt: str) -> str:
9
+ """Like ``value.strftime(fmt)``, but ``%-d`` (day without a leading zero) also works
10
+ on Windows, whose C library doesn't support the ``-`` flag."""
11
+ return value.strftime(fmt.replace("%-d", str(value.day)))
keptwise/db.py ADDED
@@ -0,0 +1,212 @@
1
+ """SQLite storage with versioned schema migrations (``PRAGMA user_version``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sqlite3
7
+ import sys
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+
13
+ BUCKETS: dict[str, str] = {
14
+ "regular": "Regulars",
15
+ "cash": "Cash",
16
+ "investment": "Investments",
17
+ "adhoc": "Ad hoc",
18
+ }
19
+
20
+ PROJECT_ROOT = Path(__file__).resolve().parents[2]
21
+
22
+
23
+ def default_db_path() -> Path:
24
+ """Where the database lives.
25
+
26
+ 1. ``$KEPTWISE_DB`` (or the older ``$BOOKKEEPING_DB``) if set.
27
+ 2. ``data/keptwise.db`` inside the project, when running from a source checkout.
28
+ A ``data/bookkeeping.db`` from before the rename is used if it exists.
29
+ 3. Otherwise the usual per-user data folder for the operating system.
30
+ """
31
+ env = os.environ.get("KEPTWISE_DB") or os.environ.get("BOOKKEEPING_DB")
32
+ if env:
33
+ return Path(env).expanduser()
34
+ if (PROJECT_ROOT / "pyproject.toml").exists():
35
+ legacy = PROJECT_ROOT / "data" / "bookkeeping.db"
36
+ return legacy if legacy.exists() else PROJECT_ROOT / "data" / "keptwise.db"
37
+ return user_data_dir() / "keptwise.db"
38
+
39
+
40
+ def user_data_dir() -> Path:
41
+ home = Path.home()
42
+ if sys.platform == "darwin":
43
+ return home / "Library" / "Application Support" / "Keptwise"
44
+ if sys.platform == "win32":
45
+ return Path(os.environ.get("APPDATA", home / "AppData" / "Roaming")) / "Keptwise"
46
+ return Path(os.environ.get("XDG_DATA_HOME", home / ".local" / "share")) / "keptwise"
47
+
48
+
49
+ MIGRATIONS: list[str] = [
50
+ # 1: initial schema
51
+ """
52
+ CREATE TABLE categories (
53
+ id INTEGER PRIMARY KEY,
54
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE,
55
+ bucket TEXT NOT NULL CHECK (bucket IN ('regular','cash','investment','adhoc')),
56
+ invest_share REAL CHECK (invest_share IS NULL OR (invest_share >= 0 AND invest_share <= 1))
57
+ );
58
+
59
+ CREATE TABLE rules (
60
+ id INTEGER PRIMARY KEY,
61
+ pattern TEXT NOT NULL,
62
+ match_type TEXT NOT NULL DEFAULT 'contains'
63
+ CHECK (match_type IN ('contains','starts_with','exact','regex')),
64
+ direction TEXT NOT NULL DEFAULT 'any' CHECK (direction IN ('any','in','out')),
65
+ tx_type TEXT,
66
+ category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
67
+ priority INTEGER NOT NULL DEFAULT 0,
68
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
69
+ );
70
+
71
+ CREATE TABLE imports (
72
+ id INTEGER PRIMARY KEY,
73
+ filename TEXT NOT NULL,
74
+ sha256 TEXT NOT NULL,
75
+ source TEXT NOT NULL,
76
+ imported_at TEXT NOT NULL DEFAULT (datetime('now')),
77
+ rows_total INTEGER NOT NULL,
78
+ rows_added INTEGER NOT NULL,
79
+ rows_duplicate INTEGER NOT NULL,
80
+ first_date TEXT,
81
+ last_date TEXT
82
+ );
83
+
84
+ CREATE TABLE transactions (
85
+ id INTEGER PRIMARY KEY,
86
+ fingerprint TEXT NOT NULL UNIQUE,
87
+ date TEXT NOT NULL,
88
+ tx_type TEXT NOT NULL DEFAULT '',
89
+ description TEXT NOT NULL,
90
+ amount INTEGER NOT NULL,
91
+ balance INTEGER,
92
+ category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
93
+ rule_id INTEGER REFERENCES rules(id) ON DELETE SET NULL,
94
+ manual INTEGER NOT NULL DEFAULT 0,
95
+ note TEXT NOT NULL DEFAULT '',
96
+ import_id INTEGER REFERENCES imports(id) ON DELETE SET NULL
97
+ );
98
+ CREATE INDEX ix_transactions_date ON transactions(date);
99
+ CREATE INDEX ix_transactions_category ON transactions(category_id);
100
+
101
+ CREATE TABLE settings (
102
+ key TEXT PRIMARY KEY,
103
+ value TEXT NOT NULL
104
+ );
105
+ """,
106
+ # 2: links to investment accounts, and which investment is an ISA
107
+ """
108
+ ALTER TABLE categories ADD COLUMN url TEXT;
109
+ ALTER TABLE categories ADD COLUMN is_isa INTEGER NOT NULL DEFAULT 0;
110
+ UPDATE categories SET url = 'https://secure.vanguardinvestor.co.uk/', is_isa = 1
111
+ WHERE name = 'Index' AND bucket = 'investment';
112
+ UPDATE categories SET url = 'https://www.kraken.com/sign-in'
113
+ WHERE name = 'BTC' AND bucket = 'investment';
114
+ """,
115
+ # 3: fewer ad hoc categories (see categories.simplify)
116
+ "keptwise.categories:simplify",
117
+ ]
118
+
119
+
120
+ def connect(path: Path | str | None = None) -> sqlite3.Connection:
121
+ """Open (and migrate) the database. ``":memory:"`` is supported for tests."""
122
+ path = default_db_path() if path is None else path
123
+ if str(path) != ":memory:":
124
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
125
+ if _needs_upgrade(Path(path)):
126
+ backup(path, force=True) # always keep a copy from before an upgrade
127
+ conn = sqlite3.connect(str(path), detect_types=0, check_same_thread=False)
128
+ conn.row_factory = sqlite3.Row
129
+ conn.execute("PRAGMA foreign_keys = ON")
130
+ migrate(conn)
131
+ return conn
132
+
133
+
134
+ def _needs_upgrade(path: Path) -> bool:
135
+ if not path.exists() or path.stat().st_size == 0:
136
+ return False
137
+ conn = sqlite3.connect(str(path))
138
+ try:
139
+ version = conn.execute("PRAGMA user_version").fetchone()[0]
140
+ finally:
141
+ conn.close()
142
+ return 0 < version < len(MIGRATIONS)
143
+
144
+
145
+ def migrate(conn: sqlite3.Connection) -> None:
146
+ version = conn.execute("PRAGMA user_version").fetchone()[0]
147
+ for index, script in enumerate(MIGRATIONS[version:], start=version + 1):
148
+ if script.startswith("keptwise."):
149
+ # A Python migration, written as "module:function".
150
+ import importlib
151
+
152
+ module, func = script.split(":")
153
+ with conn:
154
+ getattr(importlib.import_module(module), func)(conn)
155
+ conn.execute(f"PRAGMA user_version = {index}")
156
+ continue
157
+ with conn:
158
+ conn.executescript(script)
159
+ conn.execute(f"PRAGMA user_version = {index}")
160
+
161
+
162
+ @contextmanager
163
+ def transaction(conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
164
+ """Commit on success, roll back on error."""
165
+ try:
166
+ yield conn
167
+ conn.commit()
168
+ except Exception:
169
+ conn.rollback()
170
+ raise
171
+
172
+
173
+ def get_setting(conn: sqlite3.Connection, key: str, default: str | None = None) -> str | None:
174
+ row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
175
+ return row["value"] if row else default
176
+
177
+
178
+ def set_setting(conn: sqlite3.Connection, key: str, value: str) -> None:
179
+ conn.execute(
180
+ "INSERT INTO settings(key, value) VALUES(?, ?) "
181
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
182
+ (key, value),
183
+ )
184
+
185
+
186
+ def backup(path: Path | str | None = None, *, keep: int = 30, force: bool = False) -> Path | None:
187
+ """Copy the database into ``backups/`` next to it, at most once a day.
188
+
189
+ Uses SQLite's online backup, so it's safe while the app is running. Keeps the
190
+ newest ``keep`` copies. Returns the new backup's path, or None if none was made.
191
+ """
192
+ path = Path(default_db_path() if path is None else path)
193
+ if str(path) == ":memory:" or not path.exists():
194
+ return None
195
+ folder = path.parent / "backups"
196
+ folder.mkdir(exist_ok=True)
197
+ existing = sorted(folder.glob(f"{path.stem}-*.db"))
198
+ today = datetime.now().strftime("%Y%m%d")
199
+ if not force and any(p.stem.split("-")[-2] == today for p in existing):
200
+ return None
201
+ target = folder / f"{path.stem}-{datetime.now():%Y%m%d-%H%M%S}.db"
202
+ source = sqlite3.connect(str(path))
203
+ dest = sqlite3.connect(str(target))
204
+ try:
205
+ with dest:
206
+ source.backup(dest)
207
+ finally:
208
+ dest.close()
209
+ source.close()
210
+ for old in sorted(folder.glob(f"{path.stem}-*.db"))[:-keep]:
211
+ old.unlink()
212
+ return target
keptwise/demo.py ADDED
@@ -0,0 +1,128 @@
1
+ """A made-up year of transactions, for trying the app and for screenshots.
2
+
3
+ Everything here is invented: the employer, landlord, amounts and dates. Only well-known
4
+ public company names are used, so the starter rules recognise them as they would on a
5
+ real statement.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import random
11
+ import sqlite3
12
+ from datetime import date, timedelta
13
+
14
+ from .db import set_setting
15
+ from .importer import ParsedTransaction, import_transactions
16
+ from .investments import Platform, save_platforms
17
+
18
+ PLATFORMS = [
19
+ Platform(None, "Index fund", 0.8, "https://www.vanguardinvestor.co.uk/", True, ["VANGUARD"]),
20
+ Platform(None, "Bitcoin", 0.2, "https://www.coinbase.com/", False, ["COINBASE"]),
21
+ ]
22
+
23
+ GROCERIES = ["TESCO STORES 2231", "ALDI 114", "LIDL GB NEWTOWN", "SAINSBURYS S/MKT", "CO-OP GROUP"]
24
+ EATING = ["GREGGS PLC", "COSTA COFFEE", "NANDOS NEWTOWN", "THE OLD OAK PUB", "PRET A MANGER"]
25
+ SHOPPING = ["AMAZON.CO.UK*AB12CD", "ARGOS LTD", "BOOTS 0423", "PRIMARK NEWTOWN", "IKEA LTD"]
26
+ TRANSPORT = ["STAGECOACH BUS", "TRAINLINE.COM", "GWR TICKETS", "UBER *TRIP"]
27
+ OUTINGS = ["ODEON CINEMAS", "TICKETMASTER UK", "NATIONAL TRUST", "BOWLING WORLD"]
28
+
29
+
30
+ def months_back(today: date, count: int) -> list[date]:
31
+ """The first day of each of the ``count`` whole months before ``today``'s month."""
32
+ first = today.replace(day=1)
33
+ result = []
34
+ for _ in range(count):
35
+ first = (first - timedelta(days=1)).replace(day=1)
36
+ result.append(first)
37
+ return list(reversed(result))
38
+
39
+
40
+ def _month_end(first: date) -> date:
41
+ return (first.replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)
42
+
43
+
44
+ def _pence(rng: random.Random, low: float, high: float) -> int:
45
+ return round(rng.uniform(low, high) * 100)
46
+
47
+
48
+ def month_transactions(
49
+ first: date, rng: random.Random, index: int, last: bool
50
+ ) -> list[tuple[date, str, str, int]]:
51
+ """One month's made-up spending and income, without investments."""
52
+ end = _month_end(first)
53
+ days = end.day
54
+
55
+ def on(day: int) -> date:
56
+ return first.replace(day=min(day, days))
57
+
58
+ tx: list[tuple[date, str, str, int]] = [
59
+ (on(1), "FPO", "HOMES LETTINGS RENT", -72500),
60
+ (on(2), "DD", "OCTOPUS ENERGY", -6400 if index < 7 else -7150), # a price rise
61
+ (on(4), "DD", "THAMES WATER", -3150),
62
+ (on(8), "DD", "VIRGIN MEDIA", -3000),
63
+ (on(9), "DD", "GIFFGAFF", -1000),
64
+ (on(12), "DD", "PUREGYM LTD", -2499),
65
+ (on(14), "DEB", "NETFLIX.COM", -1099),
66
+ (on(18), "DEB", "SPOTIFY", -1199),
67
+ (on(25), "FPI", "ACME LTD SALARY", 215000 + (12000 if index >= 9 else 0)),
68
+ ]
69
+ if first.month not in (2, 3): # council tax is paid over ten months
70
+ tx.append((on(1), "DD", "NEWTOWN CITY COUNCIL", -11800))
71
+ for week in range(4):
72
+ day = 2 + week * 7
73
+ tx.append((on(day + rng.randint(0, 2)), "DEB", rng.choice(GROCERIES), -_pence(rng, 18, 55)))
74
+ tx.append((on(day + rng.randint(0, 5)), "DEB", rng.choice(EATING), -_pence(rng, 3, 24)))
75
+ tx.append((on(day + rng.randint(0, 5)), "DEB", rng.choice(TRANSPORT), -_pence(rng, 2, 9)))
76
+ for _ in range(rng.randint(1, 3)):
77
+ tx.append((on(rng.randint(1, days)), "DEB", rng.choice(SHOPPING), -_pence(rng, 6, 60)))
78
+ if rng.random() < 0.6:
79
+ tx.append((on(rng.randint(1, days)), "DEB", rng.choice(OUTINGS), -_pence(rng, 9, 40)))
80
+ if rng.random() < 0.5:
81
+ tx.append((on(rng.randint(1, days)), "CPT", "NOTEMACHINE NEWTOWN", -2000))
82
+ if rng.random() < 0.4:
83
+ tx.append((on(rng.randint(1, days)), "FPI", "VINTED", _pence(rng, 8, 30)))
84
+ if rng.random() < 0.4:
85
+ tx.append((on(rng.randint(1, days)), "FPO", "A FRIEND", -2000))
86
+ if first.month == 8: # a summer trip
87
+ tx.append((on(3), "DEB", "EASYJET", -18900))
88
+ tx.append((on(3), "DEB", "AIRBNB * HMXYZ", -32400))
89
+ if last: # something for the "needs a category" view
90
+ tx.append((on(20), "DEB", "SUMUP *MARKET STALL", -1250))
91
+ return tx
92
+
93
+
94
+ def build(conn: sqlite3.Connection, *, months: int = 12, today: date | None = None) -> int:
95
+ """Fill an empty, seeded database with ``months`` months of made-up data.
96
+
97
+ Returns the number of transactions added.
98
+ """
99
+ if conn.execute("SELECT 1 FROM transactions LIMIT 1").fetchone():
100
+ raise ValueError("The demo needs an empty database.")
101
+ rng = random.Random(2026) # the same demo every time
102
+ with conn:
103
+ save_platforms(conn, [Platform(**vars(p)) for p in PLATFORMS])
104
+ set_setting(conn, "bank_name", "your bank")
105
+ set_setting(conn, "setup_done", "1")
106
+
107
+ balance = 120000
108
+ kept_last_month: int | None = None
109
+ added = 0
110
+ starts = months_back(today or date.today(), months)
111
+ for index, first in enumerate(starts):
112
+ rows = month_transactions(first, rng, index, last=index == len(starts) - 1)
113
+ kept = sum(amount for _, _, _, amount in rows)
114
+ invest = max(kept_last_month if kept_last_month is not None else 30000, 0)
115
+ if invest:
116
+ day = first.replace(day=3)
117
+ for platform, text in ((PLATFORMS[0], "VANGUARD"), (PLATFORMS[1], "COINBASE")):
118
+ rows.append((day, "DEB", text, -round(invest * platform.share)))
119
+ kept_last_month = kept
120
+
121
+ parsed = []
122
+ for when, tx_type, description, amount in sorted(rows, key=lambda r: r[0]):
123
+ balance += amount
124
+ parsed.append(ParsedTransaction(when, tx_type, description, amount, balance))
125
+ name = f"demo-{first:%Y-%m}.csv"
126
+ result = import_transactions(conn, parsed, filename=name, source="demo", sha256=name)
127
+ added += result.rows_added
128
+ return added