Skip to content

Add bulk_update_transactions, using Monarch's own bulk endpoint - #151

Open
rlorenzo wants to merge 2 commits into
robcerda:mainfrom
rlorenzo:bulk-update-merchant
Open

rlorenzo wants to merge 2 commits into
robcerda:mainfrom
rlorenzo:bulk-update-merchant

Conversation

@rlorenzo

@rlorenzo rlorenzo commented Sep 18, 2026

Copy link
Copy Markdown

Applying the same edit to a set of transactions meant one update_transaction
call per row. Sets run to hundreds. Each row is a single user decision.

bulk_update_transactions(transaction_ids, ..., dry_run) does it in one
request.

Monarch has a bulk mutation

The web client calls Common_BulkUpdateTransactionsMutation when you select
transactions and edit them, so a whole selection costs one round trip. The
client library does not wrap it. gql_call reaches it anyway, so nothing needs
forking.

gql_call ships in monarchmoneycommunity 1.5.2, the pinned floor, with the
signature this uses, and the mutation rides the existing authenticated session,
so it costs no extra API call and adds no dependency.

Which fields it accepts

updates takes TransactionUpdateParams, the shape a single update takes. I
confirmed which names it accepts by sending each one with an empty selection,
where an unknown field fails validation before anything can be written:

Accepted Rejected
categoryId, merchantName, notes, goalId, reviewStatus, date merchant, hideFromReports, needsReview, amount

So the merchant field is merchantName, not merchant. The review flag is the
reviewStatus enum rather than a boolean, so needs_review maps onto it.
Hiding from reports has no bulk form at all.

date is accepted but left out anyway. Overwriting dates across a set is
rarely meant and cannot be undone without the original values.

What it is for

Clearing needs_review is the everyday case. A review queue is where sets run
longest, and bulk_update_transactions(ids, needs_review=False) empties a
stretch of it in one request.

Reassigning merchant_name is the case with no alternative. There is no merge
endpoint, and update_merchant refuses to rename one merchant onto another,
failing with A merchant with this name already exists. Pointing every
transaction at the canonical name is the only way to fold a duplicate spelling
in. Spelling has to be exact: an existing name attaches to that record, a new
one creates it.

Why the code looks like this

allSelected is hardcoded False. Set it True and Monarch updates
everything matching filters rather than the listed ids, which is the difference
between editing three transactions and editing an entire account. A test pins
it.

The endpoint is all or nothing. It reports affectedCount for the batch and
cannot express a partial failure, so bulk_categorize_transactions keeps its
per-item reporting and I left it alone. This adds a tool rather than changing
one, and the diff carries no deletions outside the README.

A refusal comes back as success: false with a null errors object, so the
code checks that flag rather than inferring success from absent errors. An
update carrying no fields fails too. Monarch would accept it, change nothing,
and not complain.

One thing carried over

_require_nonblank rejects whitespace-only merchant names on every path that
writes one. Monarch makes a merchant out of whatever string it is handed, so a
blank name creates a junk merchant instead of failing, and nothing surfaces the
mistake. It rides along from an earlier draft of this work.


Written with Claude Code; I verified the accepted and rejected field names
against a live Monarch account myself, and reviewed every line.

@robcerda

Copy link
Copy Markdown
Owner

Held only by an unrelated gate failure: two advisories landed against anyio 4.10.0 after the baseline was seeded, one of them critical. Fixed in #152, so this should go green on a rebase or a rerun.

Monarch makes a merchant out of whatever string it is handed, so a blank or
whitespace-only name quietly creates a junk merchant record rather than
failing. create_transaction, update_transaction and update_merchant all took
one unchecked.

Both rule tools were worse. They guarded with `if set_merchant_name:`, which
is false for "   ", so a blank name skipped the check and reached the API.

The validator lives in helpers.py because the hole belongs to the field
rather than to any one caller, and raises rather than returning, since every
tool that writes a merchant name already funnels exceptions through
json_error.
Applying the same edit to a set of transactions meant one update_transaction
call per row. Sets run to hundreds, and each is a single user decision.

Monarch has a real bulk mutation. The web client calls
Common_BulkUpdateTransactionsMutation when you select transactions and edit
them, so a whole selection costs one round trip. The client library does not
wrap it, but it exposes gql_call, so no fork of the library is needed. This is
the same endpoint f5aa023 uses for the review flag; if both land, that tool
becomes a special case of this one.

updates takes TransactionUpdateParams, the shape a single transaction update
takes, so one request can set any field it supports. Which fields those are was
confirmed against the live API by sending each name with an empty selection,
where an unknown field fails validation before anything can be written:
categoryId, merchantName, notes, goalId, reviewStatus and date are accepted,
while merchant, hideFromReports, needsReview and amount are rejected. So the
merchant field is merchantName, the review flag is the reviewStatus enum rather
than a boolean, and hiding from reports has no bulk form at all. date is
accepted but not offered, since overwriting dates across a set is rarely meant
and cannot be undone without the original values.

Reassigning merchantName is the case with no alternative. Monarch has no merge
endpoint, and update_merchant cannot rename one merchant onto another: it fails
with "A merchant with this name already exists". Pointing every transaction at
the canonical name is the only way to fold a duplicate spelling in.

allSelected is hardcoded False. Set True, Monarch updates everything matching
filters rather than the listed ids, which is the difference between editing
three transactions and editing an entire account; a test pins it.

The endpoint is all or nothing: it reports affectedCount for the batch and
cannot express a partial failure. bulk_categorize_transactions keeps its per
item reporting and is left untouched, so this adds a tool rather than changing
one. It also reports a refusal as success: false with a null errors object, so
that flag is checked alongside the payload errors rather than inferring the
write took from their absence, and an update carrying no fields is refused
because Monarch would accept it, change nothing, and not complain.
@rlorenzo
rlorenzo force-pushed the bulk-update-merchant branch from 1a22e0b to 03ab306 Compare September 20, 2026 20:05
@rlorenzo

Copy link
Copy Markdown
Author

@robcerda Thanks for considering this PR. I have rebased the branch off the latest main.

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.

2 participants