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 +21 -0
- package/README.md +119 -0
- package/package.json +62 -0
- package/src/main/api-core.js +231 -0
- package/src/main/auth.js +29 -0
- package/src/main/backup.js +48 -0
- package/src/main/db-open.js +77 -0
- package/src/main/ipc.js +187 -0
- package/src/main/main.js +125 -0
- package/src/main/migrate.js +181 -0
- package/src/main/storage/local.js +55 -0
- package/src/main/update-check.js +99 -0
- package/src/main/webserver.js +294 -0
- package/src/preload.cjs +27 -0
- package/src/renderer/assets/icon.ico +0 -0
- package/src/renderer/assets/icon.png +0 -0
- package/src/renderer/css/app.css +2 -0
- package/src/renderer/fonts/roca.otf +0 -0
- package/src/renderer/fonts/roca.woff +0 -0
- package/src/renderer/fonts/roca.woff2 +0 -0
- package/src/renderer/index.html +1136 -0
- package/src/renderer/js/app.js +185 -0
- package/src/renderer/js/boot.js +4 -0
- package/src/renderer/js/charts.js +116 -0
- package/src/renderer/js/components.js +39 -0
- package/src/renderer/js/helpers.js +76 -0
- package/src/renderer/js/views/insights.js +259 -0
- package/src/renderer/js/views/month.js +687 -0
- package/src/renderer/js/views/settings.js +353 -0
- package/src/renderer/js/web-api.js +50 -0
- package/src/renderer/manifest.json +18 -0
- package/src/renderer/vendor/alpine-collapse.min.js +1 -0
- package/src/renderer/vendor/alpine.min.js +5 -0
- package/src/renderer/vendor/apexcharts.min.js +38 -0
- package/src/server/cli.mjs +164 -0
- package/src/shared/engine.js +728 -0
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> </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
|
+
}
|
package/src/main/auth.js
ADDED
|
@@ -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
|
+
}
|