184 Commits
Author SHA1 Message Date
Lucas Winther 213b138bce fix: keep pie chart colors stable across selections
Continuous Integration / lint-and-test (push) Successful in 17s
Continuous Integration / docker-build-and-push (push) Successful in 34s
2026-10-03 20:15:58 +02:00
Lucas Winther 2bd5c754e8 feat: filter spending posts and collapse month sections
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 34s
2026-10-03 17:01:53 +02:00
Lucas Winther 5c6cee0f0b docs: refresh current behavior and outstanding work
Continuous Integration / lint-and-test (push) Successful in 18s
Continuous Integration / docker-build-and-push (push) Successful in 1m11s
2026-10-03 16:50:58 +02:00
Lucas Winther c250bbe476 feat: refine month totals and recurring purchase entry
Continuous Integration / lint-and-test (push) Successful in 29s
Continuous Integration / docker-build-and-push (push) Successful in 40s
2026-10-03 16:40:00 +02:00
Lucas Winther 7cccd7a41a feat: add month breakdown and session backups
Continuous Integration / lint-and-test (push) Successful in 17s
Continuous Integration / docker-build-and-push (push) Successful in 35s
Allow recurring costs and confirmations to remain unassigned when created from a purchase without a post. Add browser backup retention and restore controls, with coverage and documentation updates.
2026-10-01 04:01:49 +02:00
Lucas WintherandClaude Opus 5.5 019517e6e9 docs(todo): what the anchored-model merge left unfinished
Continuous Integration / lint-and-test (push) Successful in 14s
Continuous Integration / docker-build-and-push (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 16:53:22 +02:00
Lucas WintherandClaude Opus 5.5 933027cb1b Merge main into the anchored model
Main moved on by 27 commits since this branch forked, including three schema
steps of its own (budget folders, placement, nested bills). Resolutions worth
knowing:

- The anchored-model migration becomes 10 -> 11; main's 7 -> 10 run first.
- Main's nextUnconfirmedOccurrenceOnOrAfter walked the calendar grid for
  every cost, which would have shown the slot-model "next due" for an
  anchored bill in the budget tree. Both walks are now generators and the
  lookup dispatches on anchoring, like occurrencesOf.
- A folder's "Bills due" summed only occurrences, and an anchored bill's
  charges are never occurrences, so a charged phone plan read as 0 due.
  budgetTree now adds an anchored bill's charges in the month.
- Main's RecurringSection (with RecurringSplitEditor) supersedes this
  branch's split editor; the anchoring rules are ported onto it: start-date
  validation via startDateNeedsDay, month precision hidden for lastCharge,
  the anchoring control locked once a cost has charges.
- PurchaseDialog keeps main's placement control. The "Recurring cost" select
  replaces main's "Unlink from bill" checkbox, since "None" does the same.
  A bill created from the dialog nests under its post like one from Settings.
- Dropped main's "bounded projection ignores invalid confirmations" test: it
  exercised slot confirmations on a lastCharge cost, which no longer exist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 16:51:17 +02:00
Lucas WintherandClaude Opus 5.5 1ae3f9720b fix(store): an explicit source: undefined clears a purchase's tag before validation
updatePurchase resolved the pair it validates with `??`, which cannot tell
"no source key" from "source: undefined, clear the association". Untagging an
anchored charge while dropping it to a month-only date was refused, citing the
cost being removed. Resolve by key presence instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 16:42:46 +02:00
Lucas Winther dfbe584874 fix: keep unassigned purchases visible in budget
Continuous Integration / lint-and-test (push) Successful in 12s
Continuous Integration / docker-build-and-push (push) Successful in 32s
2026-09-30 16:35:44 +02:00
Lucas Winther f02c452d6c fix: nest legacy bills and stamp builds with commit
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 35s
2026-09-30 14:35:29 +02:00
Lucas Winther 85a21c58eb feat: place purchases and bills in budget hierarchy 2026-09-30 13:48:49 +02:00
Lucas Winther b0e8145d2e feat: support unassigned purchases and allocation status
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 35s
Allow month-only purchases to start finance plans. Keep unbudgeted posts distinct from explicit zero rules in overspend reporting.
2026-09-30 13:10:10 +02:00
Lucas Winther c1446db39f feat: improve recurring cost setup and folder management
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 33s
2026-09-30 01:50:48 +02:00
Lucas Winther 804f32c5fb feat: add session undo and redo
Continuous Integration / lint-and-test (push) Successful in 14s
Continuous Integration / docker-build-and-push (push) Successful in 34s
2026-09-30 01:33:29 +02:00
Lucas Winther d38584396b docs: record final folder test count
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 33s
2026-09-30 01:05:59 +02:00
Lucas Winther 581ede9b07 fix: preserve blank-name backups and open linked bill editors 2026-09-30 01:04:08 +02:00
Lucas Winther fc14341f1e docs: update folder worktree state and follow-up queue 2026-09-30 00:57:50 +02:00
Lucas Winther 228d48d978 fix: clarify currencies in mixed budget legend 2026-09-30 00:54:15 +02:00
Lucas Winther abf57a963f docs: explain budget folder placement and totals 2026-09-30 00:50:42 +02:00
Lucas Winther d3c14857fc feat: show budget folders on month page 2026-09-30 00:50:42 +02:00
Lucas Winther e715160235 fix: preserve folder rename drafts until save 2026-09-30 00:40:21 +02:00
Lucas Winther 725c836788 feat: organize posts and bills in settings 2026-09-30 00:35:45 +02:00
Lucas Winther 6de115843b feat: choose recurring bill destinations 2026-09-29 20:49:18 +02:00
Lucas Winther 1e30671d76 feat: derive folder bill totals and mixed tree 2026-09-29 20:41:38 +02:00
Lucas Winther 390abf2be4 fix: keep budget sibling order distinct at numeric limit 2026-09-29 20:36:33 +02:00
Lucas Winther 3f976e0943 feat: manage nested budget folders 2026-09-29 20:34:16 +02:00
Lucas Winther 046ca7824e fix: reject blank imported budget item names 2026-09-29 20:29:26 +02:00
Lucas Winther c34aa846b9 feat: add budget folder schema and migration 2026-09-29 20:25:47 +02:00
Lucas Winther 67c77eb247 docs: plan budget folders and recurring cost placement 2026-09-29 20:18:25 +02:00
Lucas Winther f1e42d9d77 docs: design budget folders and recurring cost placement 2026-09-29 20:13:45 +02:00
Lucas Winther 4905986292 ci: smoke-test image without binding a host port
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 37s
2026-09-29 19:59:20 +02:00
Lucas Winther eea868c422 ci: separate Gitea publishing from GitHub workflows
Continuous Integration / lint-and-test (push) Successful in 14s
Continuous Integration / docker-build-and-push (push) Failing after 26s
2026-09-29 19:56:29 +02:00
Lucas Winther ef5dfc1e6b ci: publish Docker image to Gitea registry
Continuous Integration / lint-and-test (push) Successful in 13s
Deploy to GitHub Pages / build (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Failing after 9s
Deploy to GitHub Pages / deploy (push) Failing after 3s
2026-09-29 19:52:39 +02:00
Lucas Winther aed2313167 fix(ui): harden summary and settings workflows 2026-09-29 19:41:58 +02:00
Lucas Winther c93ff9908c fix(rules): review and harden rule-history editing 2026-09-29 19:01:57 +02:00
Lucas WintherandClaude Opus 5 7f6cf4692a docs: the day-granularity guard, and two known holes in the recurring model
AGENTS.md §2 gains the guard this fix wave installed, stated as covering BOTH
an anchored cost's `startDate` and its charges' own dates — checking only the
first is what let the second through — and naming the one place the rule now
lives so a fifth copy does not appear.

Two items for docs/TODO.md, neither of them a defect this branch introduced:

- **Editing a live calendar cost re-grids it.** `updateRecurringCost` refuses
  an `anchoring` change on a charged cost but accepts `recurrence` and
  `startDate`, which for a calendar cost ARE the grid — so every charge's
  stored `occurrenceDate` can be orphaned and the paid bill counts twice.
  Reproduced and measured: a 7-day cost with its second slot settled goes from
  `expected 4000 / projected 0` to `expected 6000 / projected -2000` when `n`
  becomes 5. Silent money wrongness, and the honest fix is a product decision
  about what editing a live bill means, so the behaviour is left alone.
- **An anchored cost has no history of expectations.** Recording a charge
  erases every earlier month's expectation, so `overdue` collapses to 0 and a
  past month's `projected` moves (`remaining` does not). It follows from the
  spec, but it sits in tension with §1's "archiving must not retroactively
  remove past expected charges" and deserves saying out loud. Measured
  figures included.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 18:32:31 +02:00
Lucas WintherandClaude Opus 5 0095507522 refactor(month): recordedDateFor's anchoring parameter was dead
Both call sites passed the literal "calendar", and the second is already
guarded by `occurrence.confirmable` — which IS `cost.anchoring === "calendar"`.
So the `: null` arm was unreachable and the same decision was expressed twice.
Whether an anchored row gets a Confirm button at all is decided once, in
`ComingUpRow.confirmable`; this function only answers which date a click
records.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 18:32:31 +02:00
Lucas WintherandClaude Opus 5 3a4086807a fix(purchase): do not offer a tag the write will refuse
`offerableCosts` offered every non-archived anchored cost whatever the date, so
the default new-purchase flow — month-only, since `emptyDraft` seeds
`date: monthId` — offered the owner a choice that now fails at the boundary.
An anchored cost needs a day, the same way `newCostOptionIsSelectable` already
gates the new-cost pairings. A calendar cost is unaffected: its charge names
the slot it settles and its own date is never stepped from.

An anchored cost stays SELECTED when the date drops to month-only, because a
selection is never taken away out from under the owner — so the dialog says
what is wrong there rather than letting Save be the first place it comes up.

Two more in the same file and its neighbour:

- A recurring cost that has vanished since the dialog opened (deleted from
  Settings in another tab) made the purchase save with no association and no
  message, leaving the bill still showing as expected. It is refused now.
- The comment above `save()` described `requireConfirmationAdvances`, deleted
  in this branch. A write can still be refused; only the stated reason was
  stale.

`normalizedStartDateFor` and `isValidStartDate` both asked their own copy of
the granularity rule, and the first had `anchoring` optional — which the unit
handler omitted, silently answering "calendar" for a cost anchored to its last
charge. Both call the domain predicate now, with anchoring required.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 18:32:17 +02:00
Lucas WintherandClaude Opus 5 19087737eb fix(validation): both boundaries reject a month-only date on an anchored charge
Every guard this model shipped with checked the COST's `startDate`
granularity. Nobody checked the CHARGE's. An anchored (`lastCharge`) cost has
no slots, so `anchoredOccurrences` takes the latest charge's own
`Purchase.date` as its anchor and steps forward from it — and `Purchase.date`'s
documented shape allows "YYYY-MM". Both write paths accepted that and the next
fold took every money route down, with no way to recover from the UI: the
anchoring select is disabled on a charged cost, there is no delete action, and
the walk's precheck sits before the `endedFrom` break, so ending the cost does
not help either.

It was reachable by default, not by accident: `emptyDraft` seeds
`date: monthId`, so a new purchase starts month-only, and the dialog offered
every anchored cost regardless. `updatePurchase` accepted the same shape when
"use the month only" truncated the date of an existing anchored charge.

`requireSourcePairing` now takes the RESOLVED date, so an update touching only
the date is checked against the source already stored. Both boundaries also
route a day-shaped value through the domain's own parser, the way each already
does for `startDate`: "2026-02-30" is day-granular and calendar-impossible, and
`PURCHASE_DATE` lets it through, so it would otherwise import clean and throw
from the same frame.

Both files now ask `startDateNeedsDay`/`chargeDateNeedsDay` rather than
restating the rule, and `parseDatasetJson`'s doc no longer claims more than it
checks.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 18:32:05 +02:00
Lucas WintherandClaude Opus 5 bdb2b9d80f fix(store): 7 -> 8 expands a month-only charge date on an anchored cost
The step stripped `occurrenceDate` from an anchored cost's charges and left
`Purchase.date` alone. Under schema 7 those dates are legitimately month-only:
a monthly cost's grid was month-granular, and `confirmOccurrence` wrote
`date: overrides.date ?? occurrenceDate`, so every one-click confirmation of a
month-granular anchored bill was stored without a day. An anchored cost has no
slots, so after the upgrade that date IS the anchor the series steps from — a
realistic schema-7 dataset therefore migrated cleanly and then threw out of
every money route on first load. The owner has a phone bill; this is their
data, not a hand-edited file.

The day comes from the `occurrenceDate` about to be stripped when that slot
falls in the same month, because it is the day the owner actually settled;
otherwise the 1st, the same convention the `startDate` expansion already uses.
A slot in a DIFFERENT month is not borrowed from — that would move the
purchase, and moving money is what this step must not do.

Extending an unshipped step rather than appending a new one: 7 -> 8 exists only
on this branch, so no stored dataset has been through it.

The regression test builds a schema-7 dataset the app itself would have
written and folds the result, rather than only inspecting the shape. A second
test pins the agreement the two halves of this fix have to keep:
`parseDatasetJson` migrates before it validates, so everything this step
produces must satisfy the import boundary the next commit tightens.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 18:31:52 +02:00
Lucas WintherandClaude Opus 5 d542a6bdab fix(domain): the anchored walk is day-precise under every recurrence kind
The precheck in `anchoredOccurrences` covered `everyNMonths` only, so a
month-only starting point on an `everyNDays` or `everyNWeeks` anchored cost
fell straight through to `addDays` and threw a bare "Not a day-granular
IsoDate", naming no cost, several frames below `foldBalances` — the exact
failure the precheck exists to prevent, for two thirds of the costs that can
have it. The walk is day-precise under all three kinds, so the check is too.

The rule about WHICH dates need a day was written out separately in four
places (both write boundaries and both controls that offer the pairings). It
now lives once, in `granularity.ts`, alongside the second, different rule the
next commits need: an anchored cost has no slots, so its latest CHARGE's own
`Purchase.date` is the anchor the series steps from, whatever the kind.

Three comments corrected to describe the code that exists:

- `stepFrom` and `anchoredOccurrences` both claimed neither write boundary
  rejects a month-only `startDate`. Both do, and have since this model landed.
  Pointing the reader at the closed hole is why the open one — the CHARGE's
  own date — survived four reviews.
- `Anchoring`'s doc still said confirming the occurrence early is the whole
  mechanism. An anchored cost has no occurrence to confirm; recording a charge
  is the mechanism.
- `Occurrence.date` described slot identity as universal (it is calendar-only),
  and `confirmedBy` did not say it is unconditionally null on the anchored path.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 18:31:40 +02:00
Lucas WintherandClaude Opus 5 49811f6795 docs(recurring): two schedules, and why they must not be unified
AGENTS.md §1's old invariant was half wrong the moment anchored costs lost
their slots: occurrenceDate is identity for calendar only. The replacement says
what each schedule is and, more usefully, warns against collapsing them —
that collapse is exactly the bug where paying twice in September pushed the
next charge to November.

§2's termination bullet now describes a defensive assert rather than a live
guard, because the anchored walk steps forward from a real date and the grid
walk never rebases. Also added the anchoring-change-refused-once-charged
guard, which was undocumented in AGENTS.md despite having its own test suite
and UI-level guard.

DECISIONS.md, PRODUCT.md, ARCHITECTURE.md and TODO.md catch up to what
shipped: two projection models and the three guards it deleted, a recurring
cost charging more than once per cycle and being creatable from the purchase
dialog, the anchored walk beside the grid walk, and schema 8 at 730 tests.
The 2026-09-03 spec gets a header note pointing at the new spec and nothing
else — it stays an accurate record of what was decided then.

Demo data seeds two charges in one month on an anchored cost (Car insurance),
so the fixed behaviour is the first thing a screenshot shows.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 00:05:36 +02:00
Lucas WintherandClaude Opus 5 35caa123cc fix(purchase): stop offerableCosts crashing render on an empty date
Finding I3 (Critical): PurchaseDialog calls offerableCosts(dataset, draft.date)
unconditionally in its render body. The native <input type="date"> lets the
owner clear it, setting draft.date to "" — monthOf("") throws, uncaught,
during render. Unlike an event-handler throw an error boundary can react to,
this crashes the dialog with no interaction to undo.

monthOf itself stays strict per AGENTS.md (no softening a domain throw into a
fallback): offerableCosts now wraps it in a UI-layer isParseableMonth check
and returns no costs for an empty (or otherwise unparseable) date, rather than
reporting an error for a form the owner simply hasn't finished.

Checked the other recurringForm.ts helpers that take a date for the same
hole:
- newCostOptionIsSelectable had it too — "everyNMonths"+"calendar" read as
  selectable for ANY date length, including empty, though seedCostFromDraft
  still hands that combo's date to monthOf. Fixed the same way, so every
  "+ New recurring cost..." option is disabled while the date is empty.
- sourceFor and seedCostFromDraft are only ever called at save time, inside
  the guarded store.mutate, never from render — left strict, since a bad
  date there already surfaces as a caught save-time error exactly like any
  other bad-date save (ensureMonth's monthOf in addPurchase). Documented and
  tested that determination rather than inventing a second rule for it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-05 23:55:54 +02:00
Lucas WintherandClaude Opus 5 04a089eb06 fix(purchase): disable recurring options a month-only date would reject
Review finding I2: choosing "+ New recurring cost..." with unit "days" (or
"months" anchored to the last charge) while the purchase's date is month-only
threw a store-level error naming a field ("startDate") the purchase dialog
never shows, with no mention of the "set exact date" toggle that fixes it.

Per the controller ruling, this stops OFFERING the action instead of
rewording the throw: newCostOptionIsSelectable (src/ui/recurringForm.ts)
mirrors the store's own needsDay rule and drives `disabled` on each unit and
anchoring <option>, with a hint pointing at "set exact date". Switching to
month-only also resets a stranded selection (e.g. "days") back to the one
pairing that always works, so a previously valid choice never sits selected
in a now-disabled state.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-05 23:44:37 +02:00
Lucas WintherandClaude Opus 5 87f309a32c feat(purchase): tag a purchase to a recurring cost, or create one from it
Two requests in one control. Tagging an existing cost is the refresh: the data
runs out, the owner enters the purchase, picks the plan, and the money is
booked while the cycle restarts from that date — because the anchor is the
latest charge. The data cap stops needing a flow of its own.

"+ New recurring cost" seeds a cost from the purchase being typed, and that
purchase becomes its first charge, so an anchored cost created this way is
anchored from the moment it exists. Both land in one mutate call, so a failure
cannot leave a cost with no first charge.

A calendar cost is offered only where its grid has a slot in the purchase's
month. sourceFor resolves that slot by LOOKUP (walking occurrencesOf and
matching the purchase's month) rather than by deriving it from monthOf alone —
everyNDays + calendar is a legal combination whose slots are specific days, so
a derived slot could name one that does not exist on the grid.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-05 23:32:38 +02:00
Lucas Winther ccde30b8ea feat(settings): a recurring cost's splits are editable
The owner's first report: a cost's post was fixed at creation and could not be
changed. RecurringCost has carried splits and splitMode with the same shape as
Purchase since the first version, so this wires up a capability the data model
already had rather than adding one — and a bill can now span envelopes.

SplitEditor is narrowed from PurchaseDraft to the four fields it actually
reads. PurchaseDraft satisfies that structurally, so the dialog's call site
does not change at all.

The anchoring control is disabled once a cost has charges, matching the store's
refusal rather than offering an action that would fail on save.

Also fixes a carried item: the anchoring handler now expands a month-only
startDate in the same mutate call that switches to lastCharge anchoring,
matching the normalisation the recurrence-kind handler already had — an
everyNMonths cost anchored to lastCharge needs a day-granular startDate, and
the store validates that pairing on every write.
2026-09-05 23:18:53 +02:00
Lucas WintherandClaude Opus 5 61e3773733 feat(month): an anchored cost's future row is informational
This month's row keeps its one-click Confirm for both anchorings — for an
anchored cost that records "paid on time" at the due date, and paying a bill
on schedule is the common case that must not get slower.

A future row is different. Only a calendar cost can be paid early in a way a
button can express, because its grid does not move. Confirming a future
ANCHORED charge is the refresh, and a refresh needs a real date and possibly a
different amount — the purchase dialog's job. A button here would have to
invent a date, and inventing it is what produced the round-1 Critical.

Also rewrites expectedGroups' docstring, which still described the deleted
X1/wouldAdvancePast rule as current behaviour, and drops a stray "X1" label
left over a defensive null-guard it no longer has anything to do with.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-05 23:08:10 +02:00
Lucas Winther 7a755f6406 fix(validation): review round 1 — close the store's source hole, fix ordering and messages
I1: a calendar charge's MISSING occurrenceDate no longer reports the literal
word "undefined" (String(undefined) printed it, and that text appears
nowhere in the file someone is searching). Missing and malformed are split
into two messages; the missing one names the cost, matching the anchored
side's message.

I2: addPurchase/updatePurchase accepted an arbitrary `source` unchecked —
only confirmOccurrence built a coherent one. A later task hand-builds a
source and passes it straight to addPurchase, so this closes it at the store
layer now rather than leaving that task to carry the validation. Both
enforce the same pairing the importer does: a calendar cost's source
requires occurrenceDate, an anchored cost's source forbids it, an unknown
cost throws. updatePurchase checks the resolved pair (incoming source if
given, else the stored one), the same style already used for anchoring.

M1: the "no recurring costs" fold test added last round was a duplicate of
an existing one and couldn't fail independently. Replaced with a test that
actually discriminates which charge an anchored cost's walk steps from —
a 10-day cycle matching the gap between three charges, where anchoring off
the earliest instead of the latest changes whether anything is still
expected in the same month. Verified by making that swap in
occurrences.ts, watching it fail, and reverting.

M2: updateRecurringCost now refuses a charged cost's anchoring change
before checking startDate granularity, so a doomed request fails on the
real reason first rather than making the owner fix an unrelated date
problem and resubmit into the same wall.

Also closed two pre-existing asymmetries with json.ts: requireRecurrence
now rejects an unknown recurrence kind, and both addRecurringCost and
updateRecurringCost reject an unknown anchoring — both previously relied
on TypeScript's unions alone, which a hand-edited object or an `as` cast
does not go through.
2026-09-05 22:58:20 +02:00
Lucas Winther 77bd049fb9 test(domain): fold coverage for the anchored model, plus a startDate doc fix
fold.test.ts had no case exercising foldBalances/figuresFor against a
lastCharge cost at all. Adds: three same-cycle charges landing in one month
count fully as ACTUAL charges with nothing left EXPECTED in that month (an
anchored occurrence is only ever a future projection, never a settled slot);
and a no-recurring-costs dataset keeps projected == remaining, pinned
separately from the calendar-shaped fixture already covering that shape.

RecurringCost.startDate's doc comment still said "YYYY-MM for everyNMonths,
YYYY-MM-DD otherwise" — no longer true now that an anchored everyNMonths
cost needs a real day too. Rewritten to name the actual rule and point at
where both boundaries enforce it.
2026-09-05 22:42:35 +02:00
Lucas Winther 8d8407becf feat(validation): occurrenceDate is required by calendar, forbidden by anchored
Both boundaries enforce both directions, because a cost's anchoring now
determines what a charge against it may carry. A slot id on an anchored cost
describes a schedule the app will not compute.

startDate granularity widens: anchored monthly costs now need a real day,
since their next due date is computed day-precisely from a charge date.

Changing anchoring on a cost that already has charges is refused. Stripping
slot ids one way is mechanical; inventing them the other way is not, and a
guess would leave real money settling nothing.
2026-09-05 22:42:28 +02:00