spendwise 1.0.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 blindmikey
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ <div align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/blindmikey/spendwise/main/docs/spendwise.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/blindmikey/spendwise/main/docs/spendwise-light.svg">
5
+ <img alt="Spend Wise" height="64" src="https://raw.githubusercontent.com/blindmikey/spendwise/main/docs/spendwise-w-bg.svg">
6
+ </picture>
7
+ <div><a href="https://www.npmjs.com/package/@blindmikey/spendwise"><img alt="npm" src="https://img.shields.io/npm/v/%40blindmikey%2Fspendwise?label=npm&color=009967"></a></div>
8
+ <h2>A Simple and easy-to-use envelope-budgeting app.</h2>
9
+ <div><a href='https://ko-fi.com/G0G2231VF9' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi3.png?v=6' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a></div>
10
+ <div><span>&nbsp;</span></div>
11
+ </div>
12
+
13
+ <div align="center">
14
+ <picture>
15
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/blindmikey/spendwise/main/docs/screenshot-light.png">
16
+ <img alt="Spend Wise" src="https://raw.githubusercontent.com/blindmikey/spendwise/main/docs/screenshot-light.png">
17
+ </picture>
18
+ </div>
19
+
20
+ Spend Wise is envelope budgeting that runs where you
21
+ want it - as a desktop app, or as a small server you host yourself and reach
22
+ from any browser. Same app, same single data file.
23
+
24
+ **No account. No cloud. No subscription. No telemetry.** Your finances are a
25
+ JSON file on your own disk, and the app works with the network unplugged.
26
+
27
+ The one request it ever makes is a public read of this project's latest release
28
+ tag, so it can tell you an update exists - no identifier, nothing about your
29
+ money, and one checkbox in Settings turns it off for good.
30
+
31
+ ## What it does
32
+
33
+ **Budget in envelopes.** Each budget is a little savings account: it gets a
34
+ monthly allotment, and unspent money stays in it. Groceries left over in May is
35
+ still there in June - no zeroing out, no starting over.
36
+
37
+ **Spending flows in on its own.** Assign an expense to an envelope and it draws
38
+ from that balance instead of counting against the month twice. Every envelope
39
+ shows what's left at a glance: a full green line when untouched, amber when it's
40
+ nearly gone, red when it's overdrawn.
41
+
42
+ **Save up for the big, irregular stuff.** Goal budgets fill toward a target -
43
+ the annual insurance bill, a vacation - and stop contributing once they're full.
44
+ Give one a due date and it works out the monthly deposit for you. Spend from it
45
+ and contributions resume automatically.
46
+
47
+ **Fund budgets from a percentage.** Point an envelope at other groups and it
48
+ takes a cut of them, live - freelance tax savings at 33% of self-employment
49
+ income minus expenses, recalculated as you type and carried forward every month.
50
+
51
+ **Close out the month** when you're done: balances roll over, recurring lines
52
+ copy forward, savings gets a snapshot, and next month is waiting for you.
53
+
54
+ **See where it actually went.** Tag anything; the Insights view turns those tags
55
+ into spending and income trends, per-tag averages, and your savings trajectory
56
+ over the years.
57
+
58
+ **Budget together.** Two people editing the same month merge field by field
59
+ rather than one clobbering the other - you both keep your edits.
60
+
61
+ **Change your mind later.** Past months are view-only until you unlock them.
62
+ Edit one and every later month's balances and savings history recompute
63
+ correctly, after taking a backup and showing you exactly what changed.
64
+
65
+ ## Install
66
+
67
+ ### Desktop Releases
68
+
69
+ Grab the latest Windows installer or portable `.exe` from
70
+ [Releases](https://github.com/blindmikey/spendwise/releases). That's the whole
71
+ setup - it keeps its database in `%APPDATA%/Spend Wise/db.json`.
72
+
73
+ On macOS or Linux, run it from source (see
74
+ [docs/development.md](docs/development.md)) or host the web version below.
75
+
76
+ ## Web Version
77
+
78
+ ### NPM
79
+
80
+ If NPM is your thing - Electron is never installed, just one process owns the
81
+ database, so everyone editing through it merges cleanly.
82
+
83
+ ```bash
84
+ npm i -g spendwise
85
+ spendwise-server --set-password # everyone signs in with this
86
+ spendwise-server # → http://localhost:4180
87
+ ```
88
+
89
+ ### Docker
90
+
91
+ Or with Docker:
92
+
93
+ ```bash
94
+ docker build -t spendwise .
95
+ docker run -d --name spendwise -p 4180:4180 \
96
+ -v /srv/spendwise:/data -e FINANCES_PASSWORD=change-me spendwise
97
+ ```
98
+
99
+ The server speaks plain HTTP and has no TLS of its own. On a LAN that's fine;
100
+ before putting it on the internet, read
101
+ **[docs/hosting.md](docs/hosting.md)** - reverse proxy configs for Caddy, nginx
102
+ and Apache, every setting, and an account of the security model.
103
+
104
+ ### No server? No problem.
105
+
106
+ The desktop app can share itself. Set an app password, flip on **Web access**,
107
+ and phones and laptops on your network open the printed URL and edit the same
108
+ live data - until you close the app.
109
+
110
+ ## Docs
111
+
112
+ - **[Hosting](docs/hosting.md)** - self-hosting, Docker, reverse proxies, TLS, configuration, security model.
113
+ - **[Development](docs/development.md)** - how the money math works, architecture, scripts, tests.
114
+
115
+ ## License
116
+
117
+ MIT - see [LICENSE](LICENSE).
118
+
119
+ Icons are [Font Awesome Free](https://fontawesome.com) (CC BY 4.0).
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "spendwise",
3
+ "productName": "Spend Wise",
4
+ "version": "1.0.0",
5
+ "description": "A Simple and easy-to-use envelope-budgeting app",
6
+ "author": "blindmikey <github@blindmikey.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/blindmikey/spendwise.git"
10
+ },
11
+ "type": "module",
12
+ "main": "src/main/main.js",
13
+ "bin": {
14
+ "spendwise-server": "src/server/cli.mjs"
15
+ },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "license": "MIT",
20
+ "files": [
21
+ "src/**/*",
22
+ "!src/renderer/css/input.css",
23
+ "README.md"
24
+ ],
25
+ "scripts": {
26
+ "start": "electron .",
27
+ "serve": "node src/server/cli.mjs",
28
+ "css": "tailwindcss -i src/renderer/css/input.css -o src/renderer/css/app.css --minify",
29
+ "css:watch": "tailwindcss -i src/renderer/css/input.css -o src/renderer/css/app.css --watch",
30
+ "test": "vitest run",
31
+ "dist": "npm run css && electron-builder",
32
+ "prepublishOnly": "npm run css"
33
+ },
34
+ "dependencies": {
35
+ "lowdb": "^7.0.1"
36
+ },
37
+ "devDependencies": {
38
+ "@alpinejs/collapse": "^3.14.0",
39
+ "@tailwindcss/cli": "^4.0.0",
40
+ "alpinejs": "^3.14.0",
41
+ "apexcharts": "^4.0.0",
42
+ "electron": "^37.0.0",
43
+ "electron-builder": "^25.0.0",
44
+ "tailwindcss": "^4.0.0",
45
+ "vitest": "^3.0.0"
46
+ },
47
+ "build": {
48
+ "appId": "com.blindmikey.spendwise",
49
+ "files": [
50
+ "src/**/*",
51
+ "!src/renderer/css/input.css"
52
+ ],
53
+ "win": {
54
+ "target": ["nsis", "portable"],
55
+ "icon": "build/icon.ico"
56
+ },
57
+ "nsis": {
58
+ "oneClick": false,
59
+ "allowToChangeInstallationDirectory": true
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,231 @@
1
+ /*
2
+ * Transport-agnostic API core: the same handlers sit behind Electron IPC,
3
+ * the built-in web server, and the dev preview server. Method names match
4
+ * the renderer's window.api surface so every transport maps 1:1.
5
+ *
6
+ * `ctx` owns the LIVE lowdb instance and path, mutable so restore /
7
+ * change-location can swap them: { db, dbPath }. Handlers THROW on failure -
8
+ * each transport wraps results in { ok, ... } / { ok:false, error }.
9
+ *
10
+ * No Electron imports here - dialog-based features (export, change location,
11
+ * import-folder picking) live with their transports.
12
+ */
13
+ 'use strict';
14
+
15
+ import fs from 'node:fs';
16
+ import '../shared/engine.js';
17
+ import { backupDb, listBackups, backupPath } from './backup.js';
18
+ import { importLegacy } from './migrate.js';
19
+ import { openDb } from './db-open.js';
20
+ import { hashPassword, verifyPassword } from './auth.js';
21
+ import { createUpdateChecker, RELEASES_URL } from './update-check.js';
22
+
23
+ const E = globalThis.FinEngine;
24
+
25
+ export function createCore (ctx) {
26
+ const updater = createUpdateChecker({
27
+ currentVersion: ctx.version || '',
28
+ fetchImpl: ctx.fetchImpl, // tests inject; production uses global fetch
29
+ });
30
+ const assertRev = (expectedRev) => {
31
+ if (E.num(expectedRev) !== E.num(ctx.db.data.meta.rev)) {
32
+ throw new Error('stale: the data was changed by another session. Reload before saving.');
33
+ }
34
+ };
35
+
36
+ async function persist () {
37
+ ctx.db.data.meta.rev = E.num(ctx.db.data.meta.rev) + 1;
38
+ await ctx.db.write(); // lowdb/steno writes temp-file-then-rename (atomic)
39
+ }
40
+
41
+ const snapshot = () => ({ data: E.clone(ctx.db.data), path: ctx.dbPath, version: ctx.version || '' });
42
+
43
+ return {
44
+ async loadDb () {
45
+ await ctx.db.read();
46
+ if (!ctx.db.data) throw new Error('Database file is missing or unreadable.');
47
+ return snapshot();
48
+ },
49
+
50
+ rev () {
51
+ return { rev: E.num(ctx.db.data.meta.rev) };
52
+ },
53
+
54
+ /**
55
+ * Ask GitHub whether a newer release is tagged. Opt-out lives in
56
+ * settings so it travels with the database (a self-hoster decides once
57
+ * for everyone who uses their server). Never throws: a failed check is
58
+ * not the user's problem.
59
+ */
60
+ async checkUpdate ({ force = false } = {}) {
61
+ const enabled = ctx.db.data.settings.updateCheck !== false; // default on
62
+ if (!enabled) {
63
+ return { enabled: false, current: ctx.version || '', available: false, url: RELEASES_URL };
64
+ }
65
+ const res = await updater.check({ force });
66
+ return { enabled: true, ...res };
67
+ },
68
+
69
+ /**
70
+ * Save an OPEN month. Clean rev → write in place. Stale rev → field-
71
+ * level three-way merge against what the other session persisted
72
+ * (`base` is the month as this client loaded it), then recompute
73
+ * forward so derived chains stay consistent. True conflicts are
74
+ * reported, not fatal - co-editing must not hard-stop.
75
+ */
76
+ async saveMonth ({ key, month, base, expectedRev }) {
77
+ if (!ctx.db.data.months[key]) throw new Error('That month no longer exists - reload.');
78
+ E.applyAutoValues(month);
79
+
80
+ if (E.num(expectedRev) === E.num(ctx.db.data.meta.rev)) {
81
+ ctx.db.data.months[key] = month;
82
+ ctx.db.data.savingsHistory[key] = E.num(month.startingSavings);
83
+ await persist();
84
+ return { rev: ctx.db.data.meta.rev };
85
+ }
86
+
87
+ const theirs = ctx.db.data.months[key];
88
+ if (theirs.status === 'closed' && month.status !== 'closed') {
89
+ throw new Error(`${E.keyLabel(key)} was closed by another session. Reload before editing.`);
90
+ }
91
+ if (!base) assertRev(expectedRev); // client sent no base snapshot → legacy hard stop
92
+
93
+ const { month: merged, conflicts } = E.mergeMonth(base, month, theirs);
94
+ const staged = E.clone(ctx.db.data);
95
+ staged.months[key] = E.applyAutoValues(merged);
96
+ const { db: recomputed } = E.recomputeForward(staged, key);
97
+ ctx.db.data = recomputed;
98
+ ctx.db.data.savingsHistory[key] = E.num(ctx.db.data.months[key].startingSavings);
99
+ await persist();
100
+ return { ...snapshot(), rev: ctx.db.data.meta.rev, merged: true, conflicts };
101
+ },
102
+
103
+ // Save an edited CLOSED month: backup → apply → recompute → persist.
104
+ // History edits keep the hard rev gate - merging rewritten history is
105
+ // not worth the risk.
106
+ async saveClosedMonth ({ key, month, expectedRev }) {
107
+ assertRev(expectedRev);
108
+ backupDb(ctx.dbPath, 'pre-recompute');
109
+ const staged = E.clone(ctx.db.data);
110
+ staged.months[key] = E.applyAutoValues(month);
111
+ const { db: recomputed, changes } = E.recomputeForward(staged, key);
112
+ ctx.db.data = recomputed;
113
+ await persist();
114
+ return { ...snapshot(), changes };
115
+ },
116
+
117
+ // Close out a month: finalize it, materialize the next via rollover.
118
+ async closeMonth ({ key, month, expectedRev }) {
119
+ assertRev(expectedRev);
120
+ const nk = E.nextKey(key);
121
+ if (ctx.db.data.months[nk]) {
122
+ throw new Error(`${E.keyLabel(nk)} already exists - edit it instead of closing ${E.keyLabel(key)} again.`);
123
+ }
124
+ backupDb(ctx.dbPath, 'close-month');
125
+ E.applyAutoValues(month);
126
+ month.status = 'closed';
127
+ month.closingSavings = E.savings(month);
128
+ ctx.db.data.months[key] = month;
129
+ ctx.db.data.savingsHistory[key] = E.num(month.startingSavings);
130
+ const next = E.rollover(month, ctx.db.data.settings);
131
+ ctx.db.data.months[nk] = next;
132
+ ctx.db.data.savingsHistory[nk] = E.num(next.startingSavings);
133
+ await persist();
134
+ return { ...snapshot(), nextKey: nk };
135
+ },
136
+
137
+ // Save settings; open months re-sync to the new group list. The app
138
+ // password can NOT be changed through here - settings.auth is always
139
+ // preserved from the live db (see setPassword), so a web client can
140
+ // never overwrite it with a doctored settings blob.
141
+ async saveSettings ({ settings, expectedRev }) {
142
+ assertRev(expectedRev);
143
+ const prevInitial = E.num(ctx.db.data.settings.initialSavings);
144
+ const auth = ctx.db.data.settings.auth;
145
+ ctx.db.data.settings = { ...settings };
146
+ if (auth) ctx.db.data.settings.auth = auth;
147
+ else delete ctx.db.data.settings.auth;
148
+
149
+ for (const key of E.monthKeys(ctx.db.data)) {
150
+ if (ctx.db.data.months[key].status === 'open') {
151
+ E.syncMonthWithSettings(ctx.db.data.months[key], ctx.db.data.settings);
152
+ }
153
+ }
154
+
155
+ let changes = [];
156
+ const keys = E.monthKeys(ctx.db.data);
157
+ const newInitial = E.num(settings.initialSavings);
158
+ if (keys.length && newInitial !== prevInitial) {
159
+ backupDb(ctx.dbPath, 'initial-savings');
160
+ const first = keys[0];
161
+ ctx.db.data.months[first].startingSavings = newInitial;
162
+ ctx.db.data.savingsHistory[first] = newInitial;
163
+ const res = E.recomputeForward(ctx.db.data, first);
164
+ ctx.db.data = res.db;
165
+ changes = res.changes;
166
+ }
167
+
168
+ await persist();
169
+ return { ...snapshot(), changes };
170
+ },
171
+
172
+ // Tag add/remove applies to the field across ALL months. No rev gate:
173
+ // tag ops are semantic and commute with concurrent edits - rebasing
174
+ // them onto the current state is always correct.
175
+ async applyTagsEverywhere ({ fieldId, add, remove }) {
176
+ const monthsTouched = E.applyTagsAcrossMonths(ctx.db.data, fieldId, add || [], remove || null);
177
+ await persist();
178
+ return { rev: ctx.db.data.meta.rev, monthsTouched };
179
+ },
180
+
181
+ async listBackups () {
182
+ return { backups: listBackups(ctx.dbPath) };
183
+ },
184
+
185
+ async restoreBackup ({ name }) {
186
+ const source = backupPath(ctx.dbPath, name);
187
+ if (!fs.existsSync(source)) throw new Error('Backup not found.');
188
+ backupDb(ctx.dbPath, 'pre-restore');
189
+ fs.copyFileSync(source, ctx.dbPath);
190
+ ctx.db = await openDb(ctx.dbPath);
191
+ await persist(); // bump rev so other sessions detect the change
192
+ return snapshot();
193
+ },
194
+
195
+ // Phase 2 of legacy import (folder picking is transport-specific).
196
+ async migrateLegacy ({ folder, overrides }) {
197
+ if (!folder) return { imported: false };
198
+ const { data, summary } = importLegacy(folder, overrides || {});
199
+ backupDb(ctx.dbPath, 'pre-import');
200
+ data.meta.rev = E.num(ctx.db.data.meta.rev); // persist() bumps it
201
+ data.settings.auth = ctx.db.data.settings.auth; // password survives an import
202
+ if (!data.settings.auth) delete data.settings.auth;
203
+ ctx.db.data = data;
204
+ await persist();
205
+ return { ...snapshot(), imported: true, summary };
206
+ },
207
+
208
+ // ------------------------------------------------------ app password
209
+
210
+ authHas () {
211
+ return { hasPassword: !!(ctx.db.data.settings && ctx.db.data.settings.auth) };
212
+ },
213
+
214
+ authVerify ({ password }) {
215
+ return { valid: verifyPassword(password, ctx.db.data.settings.auth) };
216
+ },
217
+
218
+ // Set / change / remove (next = empty). Changing or removing requires
219
+ // the current password.
220
+ async authSet ({ current, next }) {
221
+ const auth = ctx.db.data.settings.auth;
222
+ if (auth && !verifyPassword(current || '', auth)) {
223
+ throw new Error('Current password is incorrect.');
224
+ }
225
+ if (next) ctx.db.data.settings.auth = hashPassword(next);
226
+ else delete ctx.db.data.settings.auth;
227
+ await persist();
228
+ return { hasPassword: !!next, rev: ctx.db.data.meta.rev };
229
+ },
230
+ };
231
+ }
@@ -0,0 +1,29 @@
1
+ /*
2
+ * App-password hashing: node's built-in scrypt, no dependencies. The hash
3
+ * lives in db.json settings.auth so it travels with the data (desktop lock
4
+ * and web login share one password). Threat model is casual privacy - anyone
5
+ * with filesystem access to db.json can read the data regardless.
6
+ */
7
+ 'use strict';
8
+
9
+ import crypto from 'node:crypto';
10
+
11
+ const SCRYPT = { N: 16384, r: 8, p: 1 };
12
+ const KEYLEN = 64;
13
+
14
+ export function hashPassword (password) {
15
+ const salt = crypto.randomBytes(16);
16
+ const hash = crypto.scryptSync(String(password), salt, KEYLEN, SCRYPT);
17
+ return { v: 1, salt: salt.toString('hex'), hash: hash.toString('hex') };
18
+ }
19
+
20
+ export function verifyPassword (password, auth) {
21
+ if (!auth || !auth.salt || !auth.hash) return false;
22
+ try {
23
+ const hash = crypto.scryptSync(String(password), Buffer.from(auth.salt, 'hex'), KEYLEN, SCRYPT);
24
+ const expected = Buffer.from(auth.hash, 'hex');
25
+ return hash.length === expected.length && crypto.timingSafeEqual(hash, expected);
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
@@ -0,0 +1,48 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+
4
+ const KEEP = 20;
5
+
6
+ export function backupsDir (dbPath) {
7
+ return path.join(path.dirname(dbPath), 'backups');
8
+ }
9
+
10
+ /** Copy db.json to backups/db-<timestamp>.json before a risky write. Keeps last 20. */
11
+ export function backupDb (dbPath, reason = 'backup') {
12
+ if (!fs.existsSync(dbPath)) return null;
13
+ const dir = backupsDir(dbPath);
14
+ fs.mkdirSync(dir, { recursive: true });
15
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
16
+ const name = `db-${stamp}-${reason}.json`;
17
+ fs.copyFileSync(dbPath, path.join(dir, name));
18
+ prune(dir);
19
+ return name;
20
+ }
21
+
22
+ function prune (dir) {
23
+ const files = fs.readdirSync(dir)
24
+ .filter((f) => f.startsWith('db-') && f.endsWith('.json'))
25
+ .sort();
26
+ while (files.length > KEEP) {
27
+ fs.unlinkSync(path.join(dir, files.shift()));
28
+ }
29
+ }
30
+
31
+ export function listBackups (dbPath) {
32
+ const dir = backupsDir(dbPath);
33
+ if (!fs.existsSync(dir)) return [];
34
+ return fs.readdirSync(dir)
35
+ .filter((f) => f.startsWith('db-') && f.endsWith('.json'))
36
+ .sort()
37
+ .reverse()
38
+ .map((name) => {
39
+ const stat = fs.statSync(path.join(dir, name));
40
+ return { name, size: stat.size, mtime: stat.mtime.toISOString() };
41
+ });
42
+ }
43
+
44
+ export function backupPath (dbPath, name) {
45
+ // guard against path escape - only bare filenames from listBackups are valid
46
+ if (path.basename(name) !== name) throw new Error('Invalid backup name');
47
+ return path.join(backupsDir(dbPath), name);
48
+ }
@@ -0,0 +1,77 @@
1
+ /*
2
+ * Electron-free database opener + lazy migrations, shared by the Electron
3
+ * main process (storage/local.js), the web server, and the dev preview
4
+ * server - one place for healing logic instead of three copies.
5
+ */
6
+ 'use strict';
7
+
8
+ import path from 'node:path';
9
+ import fs from 'node:fs';
10
+ import { Low } from 'lowdb';
11
+ import { JSONFile } from 'lowdb/node';
12
+ import '../shared/engine.js';
13
+
14
+ const E = globalThis.FinEngine;
15
+
16
+ export function currentMonthKey () {
17
+ const d = new Date();
18
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
19
+ }
20
+
21
+ /** Open (creating with default data if missing) the lowdb database at dbPath. */
22
+ export async function openDb (dbPath) {
23
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
24
+ const db = new Low(new JSONFile(dbPath), null);
25
+ await db.read();
26
+ if (!db.data || !db.data.meta) {
27
+ db.data = E.defaultData(currentMonthKey());
28
+ await db.write();
29
+ }
30
+ // lazy migration: seed initialSavings from the first month for older dbs
31
+ if (db.data.settings && db.data.settings.initialSavings === undefined) {
32
+ const first = E.monthKeys(db.data)[0];
33
+ db.data.settings.initialSavings = first ? E.num(db.data.months[first].startingSavings) : 0;
34
+ await db.write();
35
+ }
36
+ // heal budgetIds corrupted by the option-value fallback bug ("-" committed
37
+ // as the link target): truthy-but-dangling placeholder text → unlinked
38
+ let healed = 0;
39
+ for (const key of E.monthKeys(db.data)) {
40
+ for (const f of E.allFields(db.data.months[key])) {
41
+ if (f.budgetId === '-' || f.budgetId === '') { f.budgetId = null; healed += 1; }
42
+ }
43
+ }
44
+ if (healed) {
45
+ console.log(`[db] healed ${healed} dangling budget link(s)`);
46
+ await db.write();
47
+ }
48
+ // lazy migration: return-to-income lines no longer carry the auto tag,
49
+ // and their label prefix gained the word "budget"
50
+ let returns = 0;
51
+ for (const key of E.monthKeys(db.data)) {
52
+ for (const f of E.allFields(db.data.months[key])) {
53
+ if (!f.returnedFrom) continue;
54
+ let touched = false;
55
+ if ((f.tags || []).includes('returned-budget')) {
56
+ f.tags = f.tags.filter((t) => t !== 'returned-budget');
57
+ touched = true;
58
+ }
59
+ if ((f.label || '').startsWith('Returned from: ')) {
60
+ f.label = 'Returned from budget: ' + f.label.slice('Returned from: '.length);
61
+ touched = true;
62
+ }
63
+ if (touched) returns += 1;
64
+ }
65
+ }
66
+ if (returns) {
67
+ console.log(`[db] migrated ${returns} return-to-income line(s)`);
68
+ await db.write();
69
+ }
70
+ // lazy migration: months know their own key (scheduled goals need it)
71
+ let stamped = 0;
72
+ for (const key of E.monthKeys(db.data)) {
73
+ if (db.data.months[key].key !== key) { db.data.months[key].key = key; stamped += 1; }
74
+ }
75
+ if (stamped) await db.write();
76
+ return db;
77
+ }