Skip to content

Surface Monarch's Flexible budget bucket in get_budgets, with a safe fallback - #104

Open
alex-zwingli wants to merge 10 commits into
robcerda:mainfrom
alex-zwingli:fix/flex-budget-bucket
Open

alex-zwingli wants to merge 10 commits into
robcerda:mainfrom
alex-zwingli:fix/flex-budget-bucket

Conversation

@alex-zwingli

@alex-zwingli alex-zwingli commented Aug 16, 2026

Copy link
Copy Markdown

Fixes #103.

Under Monarch's fixed_and_flex budget system, the Flexible section carries a single amount covering every category beneath it. get_budgets never returned that number — only the individual categories inside the bucket — so spending-vs-budget for Flexible could not be computed from the tool's output.

Why it was missing

BUDGET_QUERY requested only budgetData { monthlyAmountsByCategory }. The query had been deliberately narrowed to avoid categoryGroups fields (budgetVariability, rolloverPeriod) that Monarch rejects for some accounts.

That narrowing cut wider than it needed to. The rejected fields are on the top-level categoryGroups selection; the flex data lives under budgetData, a different subtree. So the flex fields can be restored with the categoryGroups selection left byte-for-byte unchanged — which is what this PR does.

Approach: extended-first, with a safe fallback

Rather than change BUDGET_QUERY, this adds BUDGET_QUERY_FLEX alongside it — the same document plus monthlyAmountsForFlexExpense and totalsByMonth. It is tried first; if Monarch rejects those fields, the original narrow query runs and the tool behaves exactly as it does today.

The important detail is which failures count as "unsupported", and there are two traps here worth calling out.

1. Monarch's rejection text is generic. A refused field does not come back with standard GraphQL wording — probing the live API with a deliberately unknown field returns:

{'message': 'Something went wrong while processing: None on request_id: None.'}

So matching on Cannot query field / Unknown field would never fire against the real service. Detection keys on the exception type instead: gql raises TransportQueryError when the server answers with GraphQL errors, while HTTP failures raise TransportServerError and connection problems raise their own types. Text markers are kept only as a backstop for transports that do use the usual wording.

2. A failure that hits both queries isn't evidence about flex. The unsupported latch is set only after the narrow query succeeds:

except Exception as exc:
    if not _is_query_rejection(exc):
        raise                    # HTTP / connection -> surface it
    rejection = exc              # not latched yet

data = await client.gql_call(... BUDGET_QUERY ...)   # raises -> propagates
if rejection is not None:
    _flex_supported = False      # only now: flex failed, narrow worked

An expired session refuses both queries. Latching on the first failure would disable flex for the rest of the process because of an auth problem; this way it propagates and flex stays un-probed. An account that genuinely lacks the fields still pays only one failed round-trip per process rather than one per call.

Also confirmed while designing this: @include(if: false) does not let a single document carry optional flex fields — GraphQL validates the whole document before directives apply, and the live API rejects it. Two documents are genuinely required, so both render from one template to keep the categoryGroups selection structurally identical.

Output shape

get_budgets now returns an object instead of a bare list:

{
  "tool": "get_budgets",
  "args": {"start_date": null, "end_date": null},
  "data":   [ "... unchanged per-category rows ..." ],
  "budget_system": "fixed_and_flex",
  "flex":   {"status": "ok", "budget_variability": "flexible", "monthly": [ "..." ]},
  "groups": [ {"id": "...", "name": "...", "planned": 0, "actual": 0, "month": "..."} ],
  "goals":  [ {"id": "...", "name": "...", "archived": false,
               "planned_contributions": [], "actual_contributions": []} ],
  "totals": [ {"month": "...", "income": {}, "expenses": {},
               "flexible": {}, "fixed": {}, "non_monthly": {}} ]
}

Each per-category row in data also gains set_aside, rollover and rollover_type.

Two further gaps above the per-category level

Auditing what else Monarch returns above individual categories turned up two more omissions, both in data the API already sends:

  • totalsByMonth.totalIncome and .totalExpenses were never requested. Income was missing from the tool altogether — only the three expense sub-buckets were mapped. On a live account both come back fully populated.
  • monthlyAmountsByCategoryGroup was never requested. Groups budgeted at the group level rather than per category hold their amount there; it now surfaces as groups.

Both ride the same extended selection and therefore the same fallback, so an account that refuses these fields still gets every category row with flex/groups/totals marked unavailable.

groupLevelBudgetingEnabled is deliberately not requested — it sits on categoryGroups, which this PR keeps narrow. Group names are resolved from the existing narrow selection instead.

One asymmetry worth recording so it isn't mistaken for a further gap: Flexible is the only pooled bucket. Fixed and Non-Monthly are budgeted per category, so their per-category rows in data were already complete and their totals are plain sums — which is why Monarch exposes a dedicated monthlyAmountsForFlexExpense and no fixed/non-monthly equivalent. That's noted in the tool docstring.

Rollover: per-category numbers that did not add up

A further pass over the per-category fields found the most consequential omission. Monarch returns previousMonthRolloverAmount and rolloverType per category; neither was requested. For any category with rollover enabled, planned - actual therefore did not equal remaining, and nothing in the response explained the difference — it just looked like bad data.

On a live account, 5 of 86 rows were affected. With rollover included:

rows: 86
  reconcile only once rollover is included: 5
  still unexplained:                        0

Also in this pass:

  • plannedSetAsideAmount was already being requested and then silently dropped during formatting. Now mapped as set_aside.
  • budgetSystem is now requested and returned as top-level budget_system (e.g. "fixed_and_flex"), so a caller can distinguish "this account does not use flex budgeting" from "the flex bucket is empty" rather than inferring it from an empty result.

These go into the extended query only, through new template slots. The narrow fallback stays byte-for-byte the document already proven to work, and test_fallback_query_requests_nothing_beyond_the_proven_set asserts none of the extended fields leak into it — that query is the safety net, so it must never become the thing that breaks.

flex.status is deliberately explicit, because silently omitting the field is indistinguishable from a flex budget of zero — and an LLM reading it that way would state a confidently wrong number:

status meaning
ok bucket present and populated
not_configured query succeeded, but this account has no flex bucket
unsupported Monarch rejected the flex fields; fallback query ran

This is the one breaking change: callers reading the old top-level list now read data. It matches the envelope get_transactions already returns, and the README is updated accordingly.

Write side

Added set_flexible_budget, wrapping the client's existing update_flexible_budget(). This is needed because the Flex bucket is not a category group — of the 18 category groups returned on my account, none corresponds to it — so set_budget_amount(category_group_id=...) cannot reach it by construction. It refuses early with a clear message when flex is known-unsupported, instead of surfacing a raw GraphQL exception, and is added to the README's approval-required list since it mutates the ledger.

Testing

Unit: 69 budget tests, up from 6. Full suite 284 passed.

Coverage spans the flex-present / not_configured / rejection→fallback paths; that a transient rejection recovers rather than sticking; that TransportServerError propagates instead of degrading; that a failure hitting both queries surfaces as an error; rejection detection against the real generic Monarch message rather than idealized GraphQL wording; both list and bare-object shapes for monthlyAmountsForFlexExpense and the refusal to merge multiple buckets; explicit JSON nulls at every level; the income/expense marker; group-level budgets; paired-date validation in both directions at both the tool and helper level; and every branch of the two mutating tools including their confirming-payload guards.

These were validated by mutation, not by inspection. Each of the following was applied to the implementation and confirmed to fail a test: swapping the two query documents (both as constants and at the call sites), returning used_flex_query=True on the fallback path, making json_error return a success-looking payload, hardcoding a wrong current_month_range, dropping budget_system= from the destructive client call, dropping the category_type key, removing the null-guards, and disabling either confirming-payload guard. Several of those mutations passed an earlier version of this test suite, which is why the tests were rewritten.

test_fallback_query_requests_nothing_beyond_the_proven_set guards the regression this PR is most careful about: the narrow document is the safety net, so no extended field may leak into it.

Full suite: 284 passed. One unrelated failure, test_secure_session.py::TestGetAuthenticatedClient::test_no_session_returns_none, is pre-existing on main — confirmed by stashing these changes and re-running.

Worth flagging separately, since it will not reproduce in CI: that test fails only on a machine where someone has actually authenticated. _TOKEN_DIR resolves from Path.home() at import, and the fixture fakes the keyring but not the file fallback, so the test reads the developer's real ~/.monarch-mcp-server/token and gets a live session where it expects None. Running it with HOME pointed at a temp dir passes. Happy to fix that in a separate PR — it's untouched here.

Live: verified against a real fixed_and_flex account throughout. get_budgets returns 86 category rows with flex.status="ok", a populated bucket, group roll-ups, goals and all five totals; every row reconciles once rollover is included (5 previously could not, 0 remain); an explicit date range resolves to the right month; and forcing the fallback still returns all 86 rows with flex.status="unsupported" and the extended keys null — the no-regression guarantee.

Neither mutating tool was executed against the live account, since both write to a real ledger. They are covered by unit tests only, which is the main limitation of this PR's verification.

Reviewed by four independent audits (correctness, test quality, GraphQL mapping vs the vendored client, docs consistency) over three rounds. Everything they raised in scope is fixed; findings outside this PR's scope were filed separately as #105, #106 and #107 rather than folded in here.

Goals

goalsV2 comes back from the same query and was not requested either. Goal contributions are precisely what plannedSetAsideAmount refers to, so a "what is spoken for this month" answer that ignores them understates the plan. Surfaced as goals, with planned_contributions and actual_contributions per month.

Archived and completed goals are included but flagged rather than filtered, so a caller can exclude them deliberately instead of never learning they existed. Requested without upstream's @include(if: $useV2Goals) guard, since the extended document already falls back wholesale if Monarch refuses any part of it.

update_flex_rollover_settings, with its defaults removed

Also exposed, since it is the fix for a Flex bucket that has accumulated a large negative rollover over many months.

It needs care. The client signature is (rollover_start_month=None, rollover_starting_balance=0.0, rollover_enabled=True), and its own docstring offers this as the usage example:

Example (reset flex rollover to $0 starting this month): await mm.update_flex_rollover_settings()

So a zero-argument call silently discards accumulated rollover. Inheriting those defaults would hand an LLM a no-argument reset button, so both destructive arguments are required at the tool boundary, the docstring states plainly what is discarded and asks that the current rollover be reported first, and it is on the README's approval-required list. A test asserts the two arguments stay required.

Deliberately left out

  • get_cashflow_summary() — redundant. Verified against the live API that the existing get_cashflow tool already returns the same block (sumIncome, sumExpense, savings, savingsRate); this method returns that and nothing else. A second tool returning a strict subset of an existing one is surface an agent has to choose between for no gain.
  • reset_budget() — clears a whole month of planned amounts with no undo. Different blast radius from the scoped, re-settable mutations here; it deserves its own discussion before being handed to an LLM.
  • reset_budget() is the one upstream budget capability deliberately left unexposed, for the reason above.

Happy to follow up on any of these separately.

If you'd rather this were smaller

This is a large PR because the pieces share one query, one fallback path and one response shape, and splitting them means a rebase chain. But it is deliberately structured so it can be split, and I'm happy to do that instead of asking you to review it whole. Each commit is self-contained and the natural cut lines are:

Slice Contents Breaking
A Extended query + fallback machinery + per-category rollover / rollover_type / set_aside — return stays a bare list, rows just gain keys no
B The {data, flex, groups, goals, totals, budget_system} envelope yes
C Write tools: set_flexible_budget, update_flex_rollover_settings no

The one I'd most suggest, if you want only one: pull A out from under B. The rollover fix is the highest-value part — without it, planned - actual silently fails to equal remaining for any rollover category — and it needs no shape change at all. Landing A alone fixes real wrong numbers today, and leaves the list→object question to be settled separately on its own merits.

C depends only on A. B is the only piece anyone should have to argue about.

Two unrelated follow-ups I'd send separately rather than smuggle in here:

🤖 Generated with Claude Code

alex-zwingli and others added 6 commits August 15, 2026 22:23
Under Monarch's "fixed_and_flex" budget system the Flexible section carries a
single amount covering every category beneath it. The budget query requested
only monthlyAmountsByCategory, so that all-up number was never returned and
spending-vs-budget for Flexible could not be computed from the tool output.

The query had been deliberately narrowed to avoid categoryGroups fields that
Monarch rejects for some accounts. That narrowing cut wider than it needed to:
the flex data lives under budgetData, a different subtree, so it can be
restored with the categoryGroups selection left completely untouched.

- Add BUDGET_QUERY_FLEX (BUDGET_QUERY plus monthlyAmountsForFlexExpense and
  totalsByMonth), tried first and falling back to BUDGET_QUERY when Monarch
  rejects the flex fields. Only GraphQL schema rejections are cached as
  unsupported; auth and transport errors propagate rather than silently
  degrading the response into a partial answer.
- get_budgets now returns {data, flex, totals}. flex.status distinguishes
  ok / not_configured / unsupported so an unavailable bucket is never read
  as a budget of zero.
- Add set_flexible_budget, wrapping the client's update_flexible_budget().
  The bucket is not a category group, so set_budget_amount() cannot reach it.

Fixes robcerda#103

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Monarch does not return standard GraphQL validation wording for a refused
field -- it answers with a generic "Something went wrong while processing"
-- so the substring matching in _is_schema_rejection() would never have
matched a real rejection and the fallback would never have fired. Verified
against the live API with a deliberately unknown field.

- Detect refusals via gql's TransportQueryError (the server answered with
  GraphQL errors) rather than error text. HTTP failures raise
  TransportServerError and connection problems raise their own types, so
  those still propagate. Text markers remain as a backstop for transports
  that do use the usual wording.
- Only latch _flex_supported = False after the narrow query SUCCEEDS. An
  expired session refuses both queries, which is not evidence the account
  lacks a flex bucket, so that now propagates and leaves flex un-probed
  instead of disabling it for the rest of the process.
- Render both documents from a single template so the categoryGroups
  selection is structurally identical between them and cannot be widened
  by accident.
- Tests now use the error shape Monarch actually returns, rather than
  idealized GraphQL wording that only ever matched the implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewing what else sits above the per-category level turned up two more
gaps beyond the Flexible bucket, both in data Monarch already returns:

- totalsByMonth carries totalIncome and totalExpenses, and neither was
  requested. Income was absent from the tool entirely -- only the three
  expense sub-buckets were mapped.
- monthlyAmountsByCategoryGroup was never requested. Groups budgeted at
  the group level rather than per category hold their amount there.

Both are added to the same extended selection, so they share the existing
fallback: an account that refuses these fields still gets every category
row, with flex/groups/totals reported as unavailable rather than absent.

Renamed _FLEX_SELECTIONS to _EXTENDED_SELECTIONS, since the block now
covers more than flex. groupLevelBudgetingEnabled is deliberately NOT
requested: it lives on categoryGroups, which stays narrow.

Note Flexible is the only *pooled* bucket -- Fixed and Non-Monthly are
budgeted per category, so their per-category rows were already complete
and their totals are sums. Documented that in the tool docstring so the
asymmetry is not mistaken for another gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An audit of the remaining budget surface found three more omissions.

Rollover was the significant one. Monarch returns previousMonthRolloverAmount
and rolloverType per category and neither was requested, so for any category
with rollover enabled, planned - actual did not equal remaining and the
difference was unexplainable from the tool output. On a live account 5 of 86
rows were affected; with rollover included, 0 remain unexplained.

- Request previousMonthRolloverAmount and rolloverType per category, exposed
  as rollover / rollover_type.
- Map plannedSetAsideAmount, which was already being requested and then
  silently dropped during formatting, as set_aside.
- Request budgetSystem, exposed as a top-level budget_system, so a caller can
  distinguish "this account does not use flex budgeting" from "the flex
  bucket is empty" instead of inferring it.

The per-category and root additions go in the extended query only, via new
template slots. The narrow fallback stays byte-for-byte the document already
proven to work -- a new test asserts none of the extended fields leak into it,
since that query is the safety net and must never be the thing that breaks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…emoved

Adds the remaining flex-related upstream capability as a tool. It restarts the
Flex bucket rollover period, which is the fix for a bucket that has built up a
large negative rollover over many months.

The client signature defaults to rollover_starting_balance=0.0 and the current
month -- so a zero-argument call silently discards accumulated rollover. Its
own docstring gives exactly that as the usage example. Inheriting those
defaults would hand an LLM a no-argument reset button, so both destructive
arguments are required at the tool boundary and the docstring says plainly
what is discarded. Added to the README approval-required list with a note.

Deliberately NOT adding get_cashflow_summary: the existing get_cashflow tool
already returns the same summary block (sumIncome, sumExpense, savings,
savingsRate), verified against the live API, so it would be a second tool
returning a strict subset of an existing one. reset_budget stays out too --
it clears a whole month of planned amounts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
goalsV2 comes back from the same budget query and was not requested. Goal
contributions are what plannedSetAsideAmount refers to, so a "what is spoken
for this month" answer that ignores them understates the plan.

Exposed as a goals list with planned_contributions and actual_contributions
per month. Archived and completed goals are included but flagged rather than
filtered out, so a caller can exclude them deliberately instead of never
learning they exist.

Requested unconditionally rather than behind upstream's @include(if:
$useV2Goals) guard, since the whole extended document already falls back if
Monarch refuses any part of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
alex-zwingli and others added 4 commits August 17, 2026 07:31
Four independent audits (correctness, tests, GraphQL mapping, docs) ran over
the branch. Fixes for everything they found in scope.

Critical -- update_flex_rollover_settings could migrate the budget system.
The client writes budgetSystem into the mutation input and defaults it to
"fixed_and_flex", and the wrapper never passed it. On an account using another
system, a rollover reset would silently switch the household's whole budgeting
mode -- a far larger change than the one the docstring asks the user to
confirm. It now reads the account's real budgetSystem, forwards it explicitly,
and refuses when it is anything else or cannot be determined.

Removed the sticky "flex unsupported" latch. gql raises TransportQueryError
for ANY GraphQL errors array -- rate limits, resolver hiccups, partial
successes -- so a single transient error permanently stripped flex, groups,
goals, totals and rollover from every later call, and made the mutating tools
refuse with a factually false message. It was also racy: a concurrent success
could be clobbered by a concurrent failure. Re-probing costs one extra
round-trip per call on accounts that genuinely lack the fields; a sticky wrong
answer costs silently wrong financial output.

Wrong output the audits caught:
- data rows carried no income/expense marker although categoryGroups.type was
  already queried and discarded. Both are positive magnitudes, so summing
  planned across rows added income to spending. Added category_type.
- Added categories.budgetVariability: under fixed_and_flex most rows are
  pooled into the flex bucket and carry no standalone budget (57 of 86 on a
  live account), which was indistinguishable from a real zero budget.
- Added groupLevelBudgetingEnabled so a group budget can be told from a
  roll-up of its categories; adding both double-counts. The stated reason for
  omitting it was wrong -- tools/categories.py queries it successfully.
- format_budget_data used .get(k, default), which does not catch an explicit
  JSON null; four such nulls crashed the whole tool. Now matches the null-safe
  idiom used by its siblings.
- Mutating tools reported success from the absence of an exception and echoed
  the requested amount as fact. They now require a confirming payload node.
- groups/goals/totals collapsed "none returned" into the same null as "could
  not ask". [] and null now mean different things.
- format_flex_budget merged heterogeneous buckets and kept the last label.
- Partial date ranges silently inverted; now rejected.
- Dropped "did you mean"/"validation error" rejection markers: they match
  CPython suggestion text and pydantic errors, so a local bug could be read as
  "this account has no flex bucket". Classification can no longer raise.

Corrected a claim I had repeated in a comment, the docstring and the PR body:
goal contributions are NOT what plannedSetAsideAmount refers to. They are
separate quantities and adding them double-counts.

Tests: the audit proved by mutation that swapping the two query documents left
all tests passing, that the cached path's return flag was unasserted, that
test_handles_api_error also matched the success payload, and that the
default-month test restated the implementation. All four now fail on those
bugs. Added coverage for nulls, the sign marker, group-level budgets, fallback
date reuse, and transient-rejection recovery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st gaps

Second-round audits over the revised code. Two findings mattered.

The group_level_budgeting guidance was inverted and garbled in both the tool
docstring and the README: "add a group's planned to its categories' only when
group_level_budgeting is false would double-count". Read as a directive that
says add when false -- which is exactly the double-counting case. The field
added to prevent double-counting shipped with instructions to do it, in the
text the model reads at tool-call time. Now states the rule plainly: never add
the two together; the flag says which level holds the real budget.

update_flex_rollover_settings still inferred success from the absence of an
exception. The payload check added to set_flexible_budget was only half
applied, and it matters more here -- falsely confirming a destructive rollover
reset is worse than falsely confirming an amount.

Also:
- A swapped document/operation pair at the CALL SITE still passed everything;
  the guard only inspected each document in isolation. Now asserts the
  operation sent with each request is declared by the document sent with it,
  across both the extended call and the fallback.
- Half-specified date ranges were only tested in the start-only direction, and
  the helper's ValueError was untested and its type unpinned. Both directions
  and both levels now covered, including that nothing is requested first.
- format_flex_budget's multi-bucket branch was untested and still merged when
  no entry was labelled flexible -- the outcome its comment claims to prevent.
  Now takes a single bucket and warns rather than combining ambiguous ones.
- Two comments had been made false by the new template slots, including the
  "categoryGroups cannot be widened by accident" invariant the file is
  organised around.
- A null budgetSystem leaked a Python None into a user-facing refusal.
- Documented the paired-dates rule in Args; it existed only inside the raise.
- README: full response shape table (no per-row key was documented), stale
  feature bullet, and rollover_type missing from the null-on-fallback list.
- Cover goals[].completed, which no fixture exercised.

All seven round-1 mutation tests are confirmed caught by the audit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ank month

Final verification pass returned no blockers. Three follow-ups closed.

The confirming-payload guard added to update_flex_rollover_settings had no
test: mutation showed `if False and not period:` left all 45 budget tests
passing, so the guard on the single most destructive path in the file could be
deleted silently. Its sibling in set_flexible_budget was covered. Now tested.

rollover_start_month="" defeated the required-argument design. The client does
`rollover_start_month or <current month>`, so a blank value became "reset from
this month" -- exactly the silent full reset those required arguments exist to
prevent -- and the success message rendered a blank month. Malformed values
are now rejected before the mutation, and the message reports the month
Monarch actually applied rather than the one requested.

Documented that set_aside is passed through as-is and is not combined with
planned, stating plainly that the additive relationship is unverified rather
than implying one. Every set_aside value on the account available for testing
was zero, so this could not be settled empirically.

Also aligned the format_group_budgets docstring with the tool docstring and
README: adding group and category planned double-counts either way, rather
than implying a check makes it safe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flex-budgeting explanation cited concrete numbers taken from the live
account used for testing (row counts and planned expense totals), and the
usage example used that account's actual flex budget amount. Those are
personal financial figures and this is a public repo. The point they
illustrated does not need them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alex-zwingli

Copy link
Copy Markdown
Author

@robcerda This PR is ready for your review. It fixes some major gaps that prevent users from effectively budgeting with it. Thanks for getting started on this MCP server!

Let me know if you'd also like help maintaining this repo.

karbassi added a commit to karbassi/monarch-mcp-server that referenced this pull request Aug 27, 2026
…28)

Closes #17.

get_budgets returned a flat list of category rows and discarded everything that is not per-category. This account's budgetSystem is fixed_and_flex, so the flex pool and the fixed/flexible/non-monthly split were real data being dropped rather than a hypothetical gap.

Now surfaced: budget_system, flex_expense (the flexible pool, null on a fixed-only budget), and totals_by_month. None are per-category, so a flat list could not carry them -- the response is now an object with the previous rows unchanged under `categories`. That is a breaking shape change for callers, documented in the docstring.

The unit tests were insufficient here twice, and live caught both:

The query did not select these fields at all. The first pass changed only the formatter, and the tests passed against a hand-written fixture while live returned budget_system=None and empty everything. Extending BUDGET_QUERY was the actual work.

Then the fixture had the wrong shape. The monthly totals were modelled as scalars because the field names read that way (totalFixedExpenses: 900.00). They are BudgetTotals objects with plannedAmount/actualAmount/remainingAmount/previousMonthRolloverAmount -- planned versus actual being the point of a budget total. Fixture, formatter and assertions all corrected from the live response.

Also invented a GraphQL argument that does not exist, monthlyAmountsForFlexExpense(budgetVariability: FLEXIBLE), which failed the whole query. The library selects the field bare.

Two test-robustness changes came out of this:

A pre-existing test banned the token "budgetVariability" document-wide. Its commit body shows it guarded fields Monarch removed from categoryGroups, but budgetVariability is valid on monthlyAmountsForFlexExpense and returns "flexible" live. Scoped the assertion to the categoryGroups selection, then hardened it per review from a brittle .index() to a regex with an explicit assertion -- verified it still catches the original mistake even written as categoryGroups{ and reports clearly if the block disappears.

More importantly, mutation-testing the tests showed that breaking the query's flex selection failed nothing, because the budget tests drive a fixture and never see BUDGET_QUERY -- precisely the mistake made earlier in this branch, unguarded. Added a test asserting the query selects every field the formatter reads. Its first version was itself fooled by substring matching, since renaming the field to monthlyAmountsForFlexExpenseXX left the original substring present; it now matches whole tokens, confirmed by deletion-based mutation.

Ignored upstream robcerda#104's +1969 lines. Like #22 and #24, the work was a small wrapper and query change over data Monarch already returns.

Verified live: budget_system=fixed_and_flex, 68 category rows, flex pool with one month, and all five monthly buckets populated with planned/actual/remaining/rollover.

336 passed, and again under --random-order. mypy holds at 17.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@robcerda

robcerda commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Surfacing the Flexible bucket is clearly right and I like that the destructive rollover arguments are required rather than defaulted, which is the pattern I followed in #125 for update_category. It conflicts with main now, so please rebase and add README rows for any new tools.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

get_budgets omits the Flexible bucket amount: query drops monthlyAmountsForFlexExpense / totalsByMonth

2 participants