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.
@@ -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
+ });