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
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* FinEngine - pure calculation core for Spend Wise.
|
|
3
|
+
*
|
|
4
|
+
* Loaded three ways, hence the UMD-style wrapper:
|
|
5
|
+
* - renderer: plain <script src>, reads globalThis.FinEngine
|
|
6
|
+
* - Electron main (ESM): `import '../shared/engine.js'` then globalThis.FinEngine
|
|
7
|
+
* - vitest: same as main
|
|
8
|
+
*
|
|
9
|
+
* Everything here is pure: functions either read month/db objects or return
|
|
10
|
+
* new/mutated structures with no I/O, so both the live UI math and the
|
|
11
|
+
* close-out/recompute paths share one implementation.
|
|
12
|
+
*/
|
|
13
|
+
(function (root, factory) {
|
|
14
|
+
if (typeof module === 'object' && module.exports) { module.exports = factory(); }
|
|
15
|
+
root.FinEngine = factory();
|
|
16
|
+
})(globalThis, function () {
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const KIND = Object.freeze({
|
|
20
|
+
INCOME: 'income',
|
|
21
|
+
EXPENSE: 'expense',
|
|
22
|
+
ENVELOPE: 'envelope',
|
|
23
|
+
GOAL: 'goal',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
|
|
27
|
+
const isEnvelopeKind = (kind) => kind === KIND.ENVELOPE || kind === KIND.GOAL;
|
|
28
|
+
const clone = (o) => JSON.parse(JSON.stringify(o));
|
|
29
|
+
const truthy = (v) => v === true || v === 1 || v === '1';
|
|
30
|
+
|
|
31
|
+
function uuid () {
|
|
32
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
33
|
+
return crypto.randomUUID();
|
|
34
|
+
}
|
|
35
|
+
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
|
36
|
+
return ('' + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
|
|
37
|
+
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
|
|
38
|
+
}
|
|
39
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
40
|
+
const r = Math.random() * 16 | 0;
|
|
41
|
+
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------- keys
|
|
46
|
+
|
|
47
|
+
const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June',
|
|
48
|
+
'July', 'August', 'September', 'October', 'November', 'December'];
|
|
49
|
+
|
|
50
|
+
function nextKey (key) {
|
|
51
|
+
let [y, m] = key.split('-').map(Number);
|
|
52
|
+
m += 1; if (m > 12) { m = 1; y += 1; }
|
|
53
|
+
return `${y}-${String(m).padStart(2, '0')}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function prevKey (key) {
|
|
57
|
+
let [y, m] = key.split('-').map(Number);
|
|
58
|
+
m -= 1; if (m < 1) { m = 12; y -= 1; }
|
|
59
|
+
return `${y}-${String(m).padStart(2, '0')}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function keyLabel (key) {
|
|
63
|
+
const [y, m] = key.split('-').map(Number);
|
|
64
|
+
return `${MONTH_NAMES[m - 1] || '?'} ${y}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function monthIndex (key) {
|
|
68
|
+
const [y, m] = String(key).split('-').map(Number);
|
|
69
|
+
return y * 12 + (m - 1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function addMonths (key, n) {
|
|
73
|
+
const idx = monthIndex(key) + n;
|
|
74
|
+
return `${Math.floor(idx / 12)}-${String((idx % 12) + 1).padStart(2, '0')}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function monthKeys (db) { return Object.keys(db.months || {}).sort(); }
|
|
78
|
+
|
|
79
|
+
// ------------------------------------------------------------- lookups
|
|
80
|
+
|
|
81
|
+
function allFields (month) {
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const g of month.groups) out.push(...g.fields);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function findField (month, fieldId) {
|
|
88
|
+
for (const g of month.groups) {
|
|
89
|
+
const f = g.fields.find((f) => f.id === fieldId);
|
|
90
|
+
if (f) return f;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function groupOfField (month, fieldId) {
|
|
96
|
+
for (const g of month.groups) {
|
|
97
|
+
if (g.fields.some((f) => f.id === fieldId)) return g;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Sum of expense-field values linked (via budgetId) to an envelope field. */
|
|
103
|
+
function linkedSpent (month, envelopeFieldId) {
|
|
104
|
+
let sum = 0;
|
|
105
|
+
for (const g of month.groups) {
|
|
106
|
+
if (g.kind !== KIND.EXPENSE) continue;
|
|
107
|
+
for (const f of g.fields) {
|
|
108
|
+
if (f.budgetId === envelopeFieldId) sum += num(f.value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return sum;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Manual spent + spending recorded through linked expense fields. */
|
|
115
|
+
function effectiveSpent (month, field) {
|
|
116
|
+
return num(field.spent) + linkedSpent(month, field.id);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Spending beyond what the envelope held - counts against this month once. */
|
|
120
|
+
function overage (month, field) {
|
|
121
|
+
return Math.max(0, effectiveSpent(month, field) - num(field.avail));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// -------------------------------------------- auto-funded contributions
|
|
125
|
+
|
|
126
|
+
/** An auto rule is active once it has a percentage and source groups. */
|
|
127
|
+
function hasAuto (field) {
|
|
128
|
+
return !!(field && field.auto && num(field.auto.pct) > 0 && (field.auto.groups || []).length);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Signed net of the rule's source groups (income +, expense −) at FACE
|
|
133
|
+
* value: every expense counts whether or not it is assigned to a budget.
|
|
134
|
+
* Where money is paid from (envelope vs liquid) is a ledger concern -
|
|
135
|
+
* it must not move e.g. a tax-savings percentage, because an expense is
|
|
136
|
+
* still an expense for tax purposes when it draws from an envelope.
|
|
137
|
+
*/
|
|
138
|
+
function autoSourcesNet (month, rule) {
|
|
139
|
+
let net = 0;
|
|
140
|
+
for (const gid of rule.groups || []) {
|
|
141
|
+
const g = month.groups.find((g) => g.groupId === gid);
|
|
142
|
+
if (!g || isEnvelopeKind(g.kind)) continue; // sources are income/expense groups
|
|
143
|
+
let t = 0;
|
|
144
|
+
for (const f of g.fields) t += num(f.value);
|
|
145
|
+
net += (g.kind === KIND.INCOME) ? t : -t;
|
|
146
|
+
}
|
|
147
|
+
return net;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A field's effective monthly amount: auto-funded fields derive it live
|
|
152
|
+
* (e.g. "33% of Self-Employment net"), everything else uses the stored value.
|
|
153
|
+
*/
|
|
154
|
+
function fieldValue (month, field) {
|
|
155
|
+
if (hasAuto(field)) {
|
|
156
|
+
return Math.round(Math.max(0, autoSourcesNet(month, field.auto)) * num(field.auto.pct) / 100);
|
|
157
|
+
}
|
|
158
|
+
return num(field.value);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Materialize auto-funded values into storage (called on save/close/recompute). */
|
|
162
|
+
function applyAutoValues (month) {
|
|
163
|
+
for (const g of month.groups) {
|
|
164
|
+
if (!isEnvelopeKind(g.kind)) continue;
|
|
165
|
+
for (const f of g.fields) {
|
|
166
|
+
if (hasAuto(f)) f.value = fieldValue(month, f);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return month;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// -------------------------------------------------------------- totals
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The deposit a SCHEDULED goal needs this month: remaining headroom spread
|
|
176
|
+
* evenly (rounded up, so it's never underfunded) across the months left
|
|
177
|
+
* until the due date, inclusive. Past-due or missing dates fund in full.
|
|
178
|
+
*/
|
|
179
|
+
function scheduledAmount (monthKey, field, base) {
|
|
180
|
+
const headroom = Math.max(0, num(field.target) - Math.max(0, base));
|
|
181
|
+
if (!field.dueKey || !monthKey) return headroom;
|
|
182
|
+
const monthsLeft = Math.max(1, monthIndex(field.dueKey) - monthIndex(monthKey) + 1);
|
|
183
|
+
return Math.ceil(headroom / monthsLeft);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* What this month actually puts into an envelope. Plain envelopes always
|
|
188
|
+
* take the full monthly amount. Goals with a target only fill remaining
|
|
189
|
+
* headroom: at the cap they take nothing and sit until spending (linked
|
|
190
|
+
* expenses) drains the balance, which reopens headroom the same month.
|
|
191
|
+
* SCHEDULED goals (dueKey set) charge their materialized deposit - fixed
|
|
192
|
+
* for the month; paying the goal mid-cycle affects NEXT month's deposit.
|
|
193
|
+
*/
|
|
194
|
+
function contribution (month, field, kind) {
|
|
195
|
+
const rate = fieldValue(month, field);
|
|
196
|
+
if (kind !== KIND.GOAL || !(num(field.target) > 0)) return rate;
|
|
197
|
+
if (field.dueKey) return num(field.value);
|
|
198
|
+
const remain = Math.max(0, num(field.avail) - effectiveSpent(month, field));
|
|
199
|
+
return Math.min(rate, Math.max(0, num(field.target) - remain));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Income and expense amounts may carry cents, but a GROUP total never
|
|
204
|
+
* does - and it always rounds against you, so the month is never flattered:
|
|
205
|
+
* income DOWN, spending UP. Sums are accumulated in integer cents first;
|
|
206
|
+
* summing floats and rounding after would let 10.00 + 20.00 land on
|
|
207
|
+
* 30.000000000000004 and ceil to 31.
|
|
208
|
+
*/
|
|
209
|
+
const cents = (v) => Math.round(num(v) * 100);
|
|
210
|
+
|
|
211
|
+
function groupTotal (month, group) {
|
|
212
|
+
let c = 0;
|
|
213
|
+
if (group.kind === KIND.INCOME) {
|
|
214
|
+
for (const f of group.fields) c += cents(f.value);
|
|
215
|
+
return Math.floor(c / 100);
|
|
216
|
+
}
|
|
217
|
+
if (group.kind === KIND.EXPENSE) {
|
|
218
|
+
// budget-linked expenses are handled by their envelope, never double-counted
|
|
219
|
+
for (const f of group.fields) { if (!f.budgetId) c += cents(f.value); }
|
|
220
|
+
return Math.ceil(c / 100);
|
|
221
|
+
}
|
|
222
|
+
// envelopes: allotments are whole dollars, but overage comes from linked
|
|
223
|
+
// expense rows and can carry cents - it's spending, so it rounds up too
|
|
224
|
+
for (const f of group.fields) c += cents(contribution(month, f, group.kind)) + cents(overage(month, f));
|
|
225
|
+
return Math.ceil(c / 100);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function monthNet (month) {
|
|
229
|
+
let net = 0;
|
|
230
|
+
for (const g of month.groups) {
|
|
231
|
+
const t = groupTotal(month, g);
|
|
232
|
+
net += (g.kind === KIND.INCOME) ? t : -t;
|
|
233
|
+
}
|
|
234
|
+
return net;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function savings (month) { return num(month.startingSavings) + monthNet(month); }
|
|
238
|
+
|
|
239
|
+
function unspentBudgets (month) {
|
|
240
|
+
let c = 0;
|
|
241
|
+
for (const g of month.groups) {
|
|
242
|
+
if (!isEnvelopeKind(g.kind)) continue;
|
|
243
|
+
for (const f of g.fields) {
|
|
244
|
+
c += Math.max(0, cents(f.avail) - cents(effectiveSpent(month, f)));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// money still sitting in envelopes reads like income: round it DOWN so
|
|
248
|
+
// the headline total is never flattered, and never shows cents
|
|
249
|
+
return Math.floor(c / 100);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function savingsWithBudgets (month) { return savings(month) + unspentBudgets(month); }
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* FUNDING progress toward the target: how much has been set aside this
|
|
256
|
+
* cycle (avail), deliberately ignoring spending - paying the bill in the
|
|
257
|
+
* due month must not visually wipe the achievement. The next cycle's
|
|
258
|
+
* rollover resets avail, and with it the bar.
|
|
259
|
+
*/
|
|
260
|
+
function goalProgress (month, field) {
|
|
261
|
+
const target = num(field.target);
|
|
262
|
+
if (target <= 0) return { pct: 0, reached: false };
|
|
263
|
+
const avail = num(field.avail);
|
|
264
|
+
return { pct: Math.min(100, Math.round(avail / target * 100)), reached: avail >= target };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ----------------------------------------------------- month lifecycle
|
|
268
|
+
|
|
269
|
+
function strictFor (settings, groupId, fallback = false) {
|
|
270
|
+
const def = (settings.groups || []).find((g) => g.id === groupId);
|
|
271
|
+
if (def && typeof def.strictOverspend === 'boolean') return def.strictOverspend;
|
|
272
|
+
return !!fallback;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Next month's envelope balance.
|
|
277
|
+
* remain >= 0 → value + remain (surplus carries)
|
|
278
|
+
* remain < 0, strict → max(0, value + remain) (legacy one-time penalty)
|
|
279
|
+
* remain < 0, default → value (overspend already hit the ledger)
|
|
280
|
+
* `value` defaults to the stored value; pass fieldValue() for auto-funded fields.
|
|
281
|
+
*/
|
|
282
|
+
function rolloverAvail (field, effSpent, strict, value = num(field.value)) {
|
|
283
|
+
const remain = num(field.avail) - effSpent;
|
|
284
|
+
if (remain >= 0) return value + remain;
|
|
285
|
+
return strict ? Math.max(0, value + remain) : value;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function newField (kind) {
|
|
289
|
+
const f = { id: uuid(), label: '', value: 0, pinned: false, accounted: false, tags: [] };
|
|
290
|
+
if (kind === KIND.EXPENSE) f.budgetId = null;
|
|
291
|
+
if (isEnvelopeKind(kind)) { f.avail = 0; f.spent = 0; f.pinned = true; }
|
|
292
|
+
if (kind === KIND.GOAL) { f.target = null; f.dueKey = null; f.freqMonths = null; }
|
|
293
|
+
return f;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function sortedDefs (settings) {
|
|
297
|
+
return [...(settings.groups || [])].sort((a, b) => num(a.order) - num(b.order));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Build the next month from a closing month + current settings. */
|
|
301
|
+
function rollover (month, settings) {
|
|
302
|
+
const next = {
|
|
303
|
+
key: month.key ? nextKey(month.key) : undefined,
|
|
304
|
+
status: 'open',
|
|
305
|
+
startingSavings: savings(month),
|
|
306
|
+
closingSavings: null,
|
|
307
|
+
groups: [],
|
|
308
|
+
};
|
|
309
|
+
for (const def of sortedDefs(settings)) {
|
|
310
|
+
const prev = month.groups.find((g) => g.groupId === def.id);
|
|
311
|
+
const g = { groupId: def.id, title: def.title, kind: def.kind, fields: [] };
|
|
312
|
+
if (prev) {
|
|
313
|
+
for (const f of prev.fields) {
|
|
314
|
+
// budgets always carry - they're standing savings accounts,
|
|
315
|
+
// not line items, and have no pin control. Legacy imports
|
|
316
|
+
// can still arrive unpinned, so don't trust the flag here.
|
|
317
|
+
if (!f.pinned && !isEnvelopeKind(def.kind)) continue;
|
|
318
|
+
const nf = clone(f);
|
|
319
|
+
nf.accounted = false;
|
|
320
|
+
if (isEnvelopeKind(def.kind)) {
|
|
321
|
+
const eff = effectiveSpent(month, f);
|
|
322
|
+
// goals are never strict: their overspend split already
|
|
323
|
+
// paid the overflow from liquid this month
|
|
324
|
+
const strict = def.kind === KIND.ENVELOPE && strictFor(settings, def.id);
|
|
325
|
+
if (def.kind === KIND.GOAL && nf.dueKey && num(nf.target) > 0) {
|
|
326
|
+
// scheduled goal: advance a passed due date by its
|
|
327
|
+
// frequency, then materialize next month's deposit
|
|
328
|
+
const childKey = month.key ? nextKey(month.key) : null;
|
|
329
|
+
if (childKey && num(nf.freqMonths) > 0) {
|
|
330
|
+
while (monthIndex(nf.dueKey) < monthIndex(childKey)) {
|
|
331
|
+
nf.dueKey = addMonths(nf.dueKey, num(nf.freqMonths));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const base = rolloverAvail(f, eff, strict, 0);
|
|
335
|
+
nf.value = scheduledAmount(childKey, nf, base);
|
|
336
|
+
nf.avail = base + nf.value;
|
|
337
|
+
} else {
|
|
338
|
+
nf.avail = rolloverAvail(f, eff, strict, contribution(month, f, def.kind));
|
|
339
|
+
}
|
|
340
|
+
nf.spent = 0;
|
|
341
|
+
}
|
|
342
|
+
g.fields.push(nf);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
next.groups.push(g);
|
|
346
|
+
}
|
|
347
|
+
// drop budget links whose envelope didn't carry over
|
|
348
|
+
const ids = new Set(allFields(next).map((f) => f.id));
|
|
349
|
+
for (const f of allFields(next)) {
|
|
350
|
+
if (f.budgetId && !ids.has(f.budgetId)) f.budgetId = null;
|
|
351
|
+
}
|
|
352
|
+
return next;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Empty group instances for a brand-new month (first run). */
|
|
356
|
+
function materializeMonth (settings, startingSavings = 0, key = undefined) {
|
|
357
|
+
return {
|
|
358
|
+
key,
|
|
359
|
+
status: 'open',
|
|
360
|
+
startingSavings: num(startingSavings),
|
|
361
|
+
closingSavings: null,
|
|
362
|
+
groups: sortedDefs(settings).map((def) => ({
|
|
363
|
+
groupId: def.id, title: def.title, kind: def.kind, fields: [],
|
|
364
|
+
})),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Align a month's group instances with the settings group list:
|
|
370
|
+
* add instances for new groups, refresh title/kind snapshots, keep the
|
|
371
|
+
* settings order, and drop instances of deleted groups only when empty.
|
|
372
|
+
*/
|
|
373
|
+
function syncMonthWithSettings (month, settings) {
|
|
374
|
+
const defs = sortedDefs(settings);
|
|
375
|
+
const groups = [];
|
|
376
|
+
for (const def of defs) {
|
|
377
|
+
let g = month.groups.find((g) => g.groupId === def.id);
|
|
378
|
+
if (!g) g = { groupId: def.id, fields: [] };
|
|
379
|
+
g.title = def.title;
|
|
380
|
+
g.kind = def.kind;
|
|
381
|
+
groups.push(g);
|
|
382
|
+
}
|
|
383
|
+
for (const g of month.groups) {
|
|
384
|
+
if (!defs.some((d) => d.id === g.groupId) && g.fields.length > 0) {
|
|
385
|
+
groups.push(g); // orphaned but non-empty: keep, don't destroy data
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
month.groups = groups;
|
|
389
|
+
return month;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* What "Return to Income" hands back: only money committed in EARLIER
|
|
394
|
+
* months. Deleting the field also deletes this month's contribution
|
|
395
|
+
* charge, so handing that part back too would mint phantom income; and
|
|
396
|
+
* linked expenses get unlinked (re-charged as ordinary spending), so only
|
|
397
|
+
* direct/manual spending reduces the returnable balance.
|
|
398
|
+
*/
|
|
399
|
+
function returnableBalance (month, field, kind) {
|
|
400
|
+
return Math.max(0, num(field.avail) - contribution(month, field, kind) - num(field.spent));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Retire an envelope: return its previously-committed balance as an income
|
|
405
|
+
* line ("Returned from budget: <label>") and remove the envelope field. The line
|
|
406
|
+
* carries metadata (returnedFrom, returnBase) so recomputeForward can keep
|
|
407
|
+
* it consistent if the chain that fed the envelope is edited later.
|
|
408
|
+
*/
|
|
409
|
+
function emptyEnvelope (month, fieldId) {
|
|
410
|
+
const group = groupOfField(month, fieldId);
|
|
411
|
+
const field = findField(month, fieldId);
|
|
412
|
+
if (!group || !field || !isEnvelopeKind(group.kind)) return null;
|
|
413
|
+
|
|
414
|
+
const remaining = returnableBalance(month, field, group.kind);
|
|
415
|
+
const base = Math.max(0, num(field.avail) - contribution(month, field, group.kind));
|
|
416
|
+
group.fields = group.fields.filter((f) => f.id !== fieldId);
|
|
417
|
+
|
|
418
|
+
// unlink any expenses that pointed at this envelope
|
|
419
|
+
for (const f of allFields(month)) {
|
|
420
|
+
if (f.budgetId === fieldId) f.budgetId = null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let returned = null;
|
|
424
|
+
if (remaining > 0) {
|
|
425
|
+
const income = month.groups.find((g) => g.kind === KIND.INCOME);
|
|
426
|
+
if (income) {
|
|
427
|
+
returned = newField(KIND.INCOME);
|
|
428
|
+
returned.label = `Returned from budget: ${field.label || 'unnamed'}`;
|
|
429
|
+
returned.value = remaining;
|
|
430
|
+
returned.returnedFrom = fieldId;
|
|
431
|
+
returned.returnBase = base;
|
|
432
|
+
income.fields.push(returned);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return { removed: field, remaining, returned };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Tags are a property of the FIELD across its whole lifetime: apply an
|
|
440
|
+
* add/remove to every instance of the field (same id) in every month -
|
|
441
|
+
* past included. Tags never affect money math, so closed months are safe
|
|
442
|
+
* to touch and no recompute is needed.
|
|
443
|
+
*/
|
|
444
|
+
function applyTagsAcrossMonths (db, fieldId, addTags = [], removeTag = null) {
|
|
445
|
+
let touched = 0;
|
|
446
|
+
for (const key of monthKeys(db)) {
|
|
447
|
+
const f = findField(db.months[key], fieldId);
|
|
448
|
+
if (!f) continue;
|
|
449
|
+
const tags = new Set(f.tags || []);
|
|
450
|
+
for (const t of addTags) tags.add(t);
|
|
451
|
+
if (removeTag) tags.delete(removeTag);
|
|
452
|
+
f.tags = [...tags];
|
|
453
|
+
touched += 1;
|
|
454
|
+
}
|
|
455
|
+
return touched;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ------------------------------------------------------ three-way merge
|
|
459
|
+
|
|
460
|
+
const jsonEq = (a, b) => JSON.stringify(a === undefined ? null : a) === JSON.stringify(b === undefined ? null : b);
|
|
461
|
+
|
|
462
|
+
/** Union of own keys across versions, minus structurals handled separately. */
|
|
463
|
+
function mergeKeys (objs, skip) {
|
|
464
|
+
const keys = new Set();
|
|
465
|
+
for (const o of objs) { if (o) Object.keys(o).forEach((k) => keys.add(k)); }
|
|
466
|
+
for (const k of skip) keys.delete(k);
|
|
467
|
+
return [...keys];
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Three-way merge of one field's user-entered properties. Whoever changed
|
|
471
|
+
a property from base wins; both-changed-differently is a conflict and
|
|
472
|
+
MINE wins (the saver is the one looking at the screen). Tags merge as
|
|
473
|
+
sets - adds and removes from both sides both apply. */
|
|
474
|
+
function mergeField (bf, mf, tf, conflicts) {
|
|
475
|
+
const out = clone(mf);
|
|
476
|
+
for (const key of mergeKeys([bf, mf, tf], ['id', 'tags'])) {
|
|
477
|
+
const bv = bf ? bf[key] : undefined;
|
|
478
|
+
const mv = mf[key];
|
|
479
|
+
const tv = tf ? tf[key] : undefined;
|
|
480
|
+
const mineChanged = !jsonEq(mv, bv);
|
|
481
|
+
const theirsChanged = !jsonEq(tv, bv);
|
|
482
|
+
if (theirsChanged && !mineChanged) {
|
|
483
|
+
if (tv === undefined) delete out[key];
|
|
484
|
+
else out[key] = clone(tv);
|
|
485
|
+
} else if (mineChanged && theirsChanged && !jsonEq(mv, tv)) {
|
|
486
|
+
conflicts.push({ fieldId: mf.id, label: mf.label || (tf && tf.label) || '', prop: key, mine: mv, theirs: tv });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const b = new Set((bf && bf.tags) || []);
|
|
490
|
+
const t = new Set((tf && tf.tags) || []);
|
|
491
|
+
const merged = new Set(mf.tags || []);
|
|
492
|
+
for (const x of t) { if (!b.has(x)) merged.add(x); } // theirs added
|
|
493
|
+
for (const x of b) { if (!t.has(x)) merged.delete(x); } // theirs removed
|
|
494
|
+
out.tags = [...merged];
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const fieldTouched = (bf, f) => !jsonEq(
|
|
499
|
+
{ ...bf, tags: [...(bf.tags || [])].sort() },
|
|
500
|
+
{ ...f, tags: [...(f.tags || [])].sort() });
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Field-level three-way merge of one month. `base` is the snapshot the
|
|
504
|
+
* saving session originally loaded, `mine` is what it is saving now,
|
|
505
|
+
* `theirs` is what another session persisted meanwhile. Field identity is
|
|
506
|
+
* the uuid (stable across sessions). Add/delete semantics: additions from
|
|
507
|
+
* both sides survive; a deletion wins only over an untouched field -
|
|
508
|
+
* edits beat deletes (recorded as conflicts). Ordering follows mine, with
|
|
509
|
+
* theirs' additions inserted after their surviving neighbor. Derived
|
|
510
|
+
* values (avail chains, auto/scheduled deposits, closingSavings) are NOT
|
|
511
|
+
* reconciled here - run recomputeForward after merging.
|
|
512
|
+
* Returns { month, conflicts: [{fieldId, label, prop, mine, theirs}] }.
|
|
513
|
+
*/
|
|
514
|
+
function mergeMonth (base, mine, theirs) {
|
|
515
|
+
const conflicts = [];
|
|
516
|
+
base = base || theirs; // no base → treat theirs as the baseline; mine wins where it differs
|
|
517
|
+
const out = clone(mine);
|
|
518
|
+
|
|
519
|
+
// month-level scalars (status guarded by the caller; startingSavings
|
|
520
|
+
// and closingSavings are re-derived by recomputeForward anyway)
|
|
521
|
+
for (const key of mergeKeys([base, mine, theirs], ['groups', 'key'])) {
|
|
522
|
+
const bv = base[key], mv = mine[key], tv = theirs[key];
|
|
523
|
+
if (!jsonEq(tv, bv) && jsonEq(mv, bv)) {
|
|
524
|
+
if (tv === undefined) delete out[key];
|
|
525
|
+
else out[key] = clone(tv);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const groupById = (m) => new Map((m.groups || []).map((g) => [g.groupId, g]));
|
|
530
|
+
const bGroups = groupById(base), tGroups = groupById(theirs);
|
|
531
|
+
|
|
532
|
+
out.groups = (mine.groups || []).map((mg) => {
|
|
533
|
+
const bg = bGroups.get(mg.groupId);
|
|
534
|
+
const tg = tGroups.get(mg.groupId);
|
|
535
|
+
const og = { ...clone(mg) };
|
|
536
|
+
// group meta (title/kind snapshots) - theirs wins only where mine idle
|
|
537
|
+
for (const key of mergeKeys([bg, mg, tg], ['groupId', 'fields'])) {
|
|
538
|
+
const bv = bg ? bg[key] : undefined;
|
|
539
|
+
if (tg && !jsonEq(tg[key], bv) && jsonEq(mg[key], bv)) og[key] = clone(tg[key]);
|
|
540
|
+
}
|
|
541
|
+
if (!tg) return og; // group unknown to theirs - keep mine as-is
|
|
542
|
+
|
|
543
|
+
const bF = new Map(((bg && bg.fields) || []).map((f) => [f.id, f]));
|
|
544
|
+
const tF = new Map((tg.fields || []).map((f) => [f.id, f]));
|
|
545
|
+
const merged = [];
|
|
546
|
+
const seen = new Set();
|
|
547
|
+
|
|
548
|
+
for (const mf of mg.fields || []) {
|
|
549
|
+
seen.add(mf.id);
|
|
550
|
+
const bf = bF.get(mf.id);
|
|
551
|
+
const tf = tF.get(mf.id);
|
|
552
|
+
if (!bf) { // mine added (or both added same id)
|
|
553
|
+
merged.push(mergeField(null, mf, tf, conflicts));
|
|
554
|
+
} else if (!tf) { // theirs deleted
|
|
555
|
+
if (fieldTouched(bf, mf)) { // …but mine edited it - edits beat deletes
|
|
556
|
+
conflicts.push({ fieldId: mf.id, label: mf.label || '', prop: 'removed', mine: 'kept (edited here)', theirs: 'removed' });
|
|
557
|
+
merged.push(clone(mf));
|
|
558
|
+
} // untouched → honor the deletion
|
|
559
|
+
} else {
|
|
560
|
+
merged.push(mergeField(bf, mf, tf, conflicts));
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// theirs' side: additions, and deletes-by-mine that they edited
|
|
565
|
+
const tList = tg.fields || [];
|
|
566
|
+
for (let i = 0; i < tList.length; i++) {
|
|
567
|
+
const tf = tList[i];
|
|
568
|
+
if (seen.has(tf.id)) continue;
|
|
569
|
+
const bf = bF.get(tf.id);
|
|
570
|
+
if (bf && !fieldTouched(bf, tf)) continue; // mine deleted, theirs untouched → deletion wins
|
|
571
|
+
if (bf) {
|
|
572
|
+
conflicts.push({ fieldId: tf.id, label: tf.label || '', prop: 'removed', mine: 'removed', theirs: 'kept (edited there)' });
|
|
573
|
+
}
|
|
574
|
+
// insert after the nearest preceding theirs-neighbor that survived
|
|
575
|
+
let at = merged.length;
|
|
576
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
577
|
+
const idx = merged.findIndex((f) => f.id === tList[j].id);
|
|
578
|
+
if (idx !== -1) { at = idx + 1; break; }
|
|
579
|
+
}
|
|
580
|
+
merged.splice(at, 0, clone(tf));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
og.fields = merged;
|
|
584
|
+
return og;
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// groups only theirs knows about (settings sync landed there first)
|
|
588
|
+
const mineGroupIds = new Set(out.groups.map((g) => g.groupId));
|
|
589
|
+
for (const tg of theirs.groups || []) {
|
|
590
|
+
if (!mineGroupIds.has(tg.groupId)) out.groups.push(clone(tg));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return { month: out, conflicts };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ---------------------------------------------------- recompute forward
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Re-derive every month after fromKey (and fromKey's own closingSavings):
|
|
600
|
+
* startingSavings chains, envelope avail chains (matched by field id),
|
|
601
|
+
* closingSavings of closed months, and savingsHistory entries.
|
|
602
|
+
* User-entered labels/values/spent/tags in later months are never touched.
|
|
603
|
+
* Returns { db, changes } on a clone - caller decides whether to persist.
|
|
604
|
+
*/
|
|
605
|
+
function recomputeForward (db, fromKey) {
|
|
606
|
+
const out = clone(db);
|
|
607
|
+
const changes = [];
|
|
608
|
+
|
|
609
|
+
const from = out.months[fromKey];
|
|
610
|
+
if (from) {
|
|
611
|
+
from.key = fromKey;
|
|
612
|
+
applyAutoValues(from);
|
|
613
|
+
if (from.status === 'closed') from.closingSavings = savings(from);
|
|
614
|
+
if (out.savingsHistory) out.savingsHistory[fromKey] = num(from.startingSavings);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
let prevKeyOf = fromKey;
|
|
618
|
+
for (const key of monthKeys(out).filter((k) => k > fromKey)) {
|
|
619
|
+
const prev = out.months[prevKeyOf];
|
|
620
|
+
const m = out.months[key];
|
|
621
|
+
m.key = key;
|
|
622
|
+
applyAutoValues(m);
|
|
623
|
+
const ch = { key, label: keyLabel(key), startingSavings: null, envelopes: [] };
|
|
624
|
+
|
|
625
|
+
const newStart = savings(prev);
|
|
626
|
+
if (num(m.startingSavings) !== newStart) {
|
|
627
|
+
ch.startingSavings = { from: num(m.startingSavings), to: newStart };
|
|
628
|
+
m.startingSavings = newStart;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
for (const g of m.groups) {
|
|
632
|
+
if (!isEnvelopeKind(g.kind)) continue;
|
|
633
|
+
for (const f of g.fields) {
|
|
634
|
+
const pf = findField(prev, f.id);
|
|
635
|
+
if (!pf) continue; // field born this month - avail is user-entered
|
|
636
|
+
const pg = groupOfField(prev, f.id);
|
|
637
|
+
if (!pg || !isEnvelopeKind(pg.kind)) continue;
|
|
638
|
+
const gStrict = g.kind === KIND.ENVELOPE && strictFor(out.settings, g.groupId);
|
|
639
|
+
// a month's balance includes its OWN allotment (the user may
|
|
640
|
+
// have edited it after the rollover created this month)
|
|
641
|
+
const prevEff = effectiveSpent(prev, pf);
|
|
642
|
+
let incoming = fieldValue(m, f);
|
|
643
|
+
if (g.kind === KIND.GOAL && num(f.target) > 0) {
|
|
644
|
+
const base = Math.max(0, num(pf.avail) - prevEff);
|
|
645
|
+
if (f.dueKey) {
|
|
646
|
+
incoming = scheduledAmount(key, f, base);
|
|
647
|
+
f.value = incoming; // scheduled deposit follows the chain
|
|
648
|
+
} else {
|
|
649
|
+
incoming = Math.min(incoming, Math.max(0, num(f.target) - base));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const newAvail = rolloverAvail(pf, prevEff, gStrict, incoming);
|
|
653
|
+
if (num(f.avail) !== newAvail) {
|
|
654
|
+
ch.envelopes.push({ id: f.id, label: f.label, from: num(f.avail), to: newAvail });
|
|
655
|
+
f.avail = newAvail;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// envelopes deleted in this month via "Return to Income": keep the
|
|
661
|
+
// return line consistent with the recomputed chain. Delta-adjusted,
|
|
662
|
+
// so manual edits to the line survive as an offset.
|
|
663
|
+
for (const pg of prev.groups) {
|
|
664
|
+
if (!isEnvelopeKind(pg.kind)) continue;
|
|
665
|
+
for (const pf of pg.fields) {
|
|
666
|
+
if (findField(m, pf.id)) continue; // still exists - handled above
|
|
667
|
+
const rf = allFields(m).find((f) => f.returnedFrom === pf.id);
|
|
668
|
+
if (!rf) continue;
|
|
669
|
+
const pgStrict = pg.kind === KIND.ENVELOPE && strictFor(out.settings, pg.groupId);
|
|
670
|
+
const newBase = rolloverAvail(pf, effectiveSpent(prev, pf), pgStrict, 0);
|
|
671
|
+
if (newBase === num(rf.returnBase)) continue;
|
|
672
|
+
const to = Math.max(0, num(rf.value) + (newBase - num(rf.returnBase)));
|
|
673
|
+
ch.envelopes.push({ id: rf.id, label: rf.label || 'returned budget', from: num(rf.value), to });
|
|
674
|
+
rf.value = to;
|
|
675
|
+
rf.returnBase = newBase;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (m.status === 'closed') m.closingSavings = savings(m);
|
|
680
|
+
if (out.savingsHistory) out.savingsHistory[key] = num(m.startingSavings);
|
|
681
|
+
|
|
682
|
+
if (ch.startingSavings || ch.envelopes.length) changes.push(ch);
|
|
683
|
+
prevKeyOf = key;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return { db: out, changes };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// -------------------------------------------------------- default data
|
|
690
|
+
|
|
691
|
+
function defaultSettings () {
|
|
692
|
+
return {
|
|
693
|
+
currency: 'USD',
|
|
694
|
+
initialSavings: 0,
|
|
695
|
+
groups: [
|
|
696
|
+
{ id: uuid(), title: 'Income', kind: KIND.INCOME, order: 0 },
|
|
697
|
+
{ id: uuid(), title: 'Envelope Budgets', kind: KIND.ENVELOPE, order: 1, strictOverspend: false },
|
|
698
|
+
{ id: uuid(), title: 'Goal Budgets', kind: KIND.GOAL, order: 2, strictOverspend: false },
|
|
699
|
+
{ id: uuid(), title: 'Expenses', kind: KIND.EXPENSE, order: 3 },
|
|
700
|
+
],
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function defaultData (currentKey) {
|
|
705
|
+
const settings = defaultSettings();
|
|
706
|
+
const months = {};
|
|
707
|
+
if (currentKey) months[currentKey] = materializeMonth(settings, settings.initialSavings, currentKey);
|
|
708
|
+
return {
|
|
709
|
+
meta: { schemaVersion: 1, rev: 0 },
|
|
710
|
+
settings,
|
|
711
|
+
months,
|
|
712
|
+
savingsHistory: currentKey ? { [currentKey]: num(settings.initialSavings) } : {},
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return {
|
|
717
|
+
KIND, num, isEnvelopeKind, clone, truthy, uuid,
|
|
718
|
+
MONTH_NAMES, nextKey, prevKey, keyLabel, monthKeys, monthIndex, addMonths, scheduledAmount,
|
|
719
|
+
allFields, findField, groupOfField,
|
|
720
|
+
linkedSpent, effectiveSpent, overage,
|
|
721
|
+
hasAuto, autoSourcesNet, fieldValue, applyAutoValues, contribution,
|
|
722
|
+
groupTotal, monthNet, savings, unspentBudgets, savingsWithBudgets, goalProgress,
|
|
723
|
+
strictFor, rolloverAvail, newField, sortedDefs, rollover,
|
|
724
|
+
materializeMonth, syncMonthWithSettings, returnableBalance, emptyEnvelope,
|
|
725
|
+
applyTagsAcrossMonths, mergeMonth, recomputeForward,
|
|
726
|
+
defaultSettings, defaultData,
|
|
727
|
+
};
|
|
728
|
+
});
|