Skip to content

Fix YNAB batch payee updates, add YNAB integration tests#57

Merged
wesm merged 10 commits intowesm:mainfrom
tlmaloney:fix/ynab-batch-payee-updates
Dec 15, 2025
Merged

Fix YNAB batch payee updates, add YNAB integration tests#57
wesm merged 10 commits intowesm:mainfrom
tlmaloney:fix/ynab-batch-payee-updates

Conversation

@tlmaloney
Copy link
Contributor

More from the Claude cyborg department, this PR fixes bugs (raised in #56) with YNAB merchant/payee batch updates and adds a user prompt when batch renames would affect more transactions than selected. I felt like introducing a prompt gives some transparency to how YNAB behaves when bulk updating, and gives user control over whether or not they want to perform a bulk operation or edit just the transactions that are queued. Orthogonal to this, I have some $0 YNAB and Amazon transactions (voided checks - what are those?! - and some cancelled Amazon items), so I added a fill_nan to aggregate_by_merchant() so the top category calc works and doesn't raise.

Problems Solved

  1. Duplicate payee created on no-op rename - When renaming a payee back to itself (A→A), YNAB would create a duplicate payee. Now skipped.
  2. Duplicate payee when target exists - When renaming to an existing payee name, a duplicate was created. Now uses batch transaction reassignment instead.
  3. Local cache out of sync after batch updates - Batch updates affected all transactions with a payee, but only the queued transaction was updated locally. Now correctly updates all matching transactions.
  4. Backend-specific behavior - YNAB uses bulk payee renames, Monarch Money uses individual transaction updates. Now supports both via bulk_merchant_renames tracking.
  5. Batch scope mismatch - User selects 3 transactions but batch affects 15. Now prompts user to choose "Rename all 15" or "Rename selected 3 only".

Changes

  | File                          | Changes                                                                              |
  |-------------------------------|--------------------------------------------------------------------------------------|
  | ynab_client.py                | Add no-op skip, batch reassign for existing payees, get_transaction_count_by_payee() |
  | backends/base.py              | Add optional get_transaction_count_by_merchant()                                     |
  | backends/ynab.py              | Implement get_transaction_count_by_merchant()                                        |
  | commit_orchestrator.py        | Add apply_bulk_merchant_edit() for YNAB, keep apply_merchant_edit() for Monarch      |
  | data_manager.py               | Add check_batch_scope(), skip_batch_for parameter, return bulk_merchant_renames      |
  | app_controller.py             | Pass bulk_merchant_renames to orchestrator                                           |
  | app.py                        | Integrate batch scope prompt flow                                                    |
  | screens/batch_scope_screen.py | New - Modal prompt for user choice                                                   |
  | notification_helper.py        | Add commit_cancelled()                                                               |

Manual Test Plan

  • Manual test: Rename merchant affecting more transactions than selected → prompt appears
  • Manual test: Choose "Rename all" → batch update used
  • Manual test: Choose "Rename selected only" → individual updates used
  • Manual test: Choose "Cancel" → commit aborted

Tom Maloney and others added 5 commits December 7, 2025 19:51
This commit fixes several issues with YNAB payee/merchant batch updates:

1. Skip no-op renames: When renaming a payee to itself (A→A), skip the
   API call entirely to avoid creating duplicates.

2. Batch reassign for existing payees: When the target payee already
   exists, use YNAB's batch transaction update API to reassign all
   transactions to the existing payee (2 API calls) instead of falling
   back to individual updates (N+1 API calls).

3. Bulk local cache update: Changed CommitOrchestrator.apply_merchant_edit
   to update ALL transactions with the old merchant name, not just the
   specific transaction ID. This matches YNAB's batch behavior and ensures
   the local cache stays in sync after commits.

Performance:
- New payee name: 1 API call (unchanged)
- Existing payee: 2 API calls (was N+1)
- No-op rename: 0 API calls (was 1+)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This adds backend-specific behavior for local cache updates:

- Monarch Money: Updates only the specific transaction (by ID)
- YNAB: Updates ALL transactions with the old merchant name (bulk)

The behavior is controlled by the `bulk_merchant_renames` parameter:
- `commit_pending_edits` now returns a set of (old, new) tuples for
  merchant renames that were batch-updated on the backend
- `CommitOrchestrator.apply_edits_to_dataframe` accepts this set and
  uses bulk updates only for matching renames
- When `bulk_merchant_renames` is None or empty, single-transaction
  updates are used (backwards compatible)

New methods:
- `apply_merchant_edit(df, transaction_id, new_merchant)` - single txn
- `apply_bulk_merchant_edit(df, old_merchant, new_merchant)` - all matching

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
When renaming merchants in YNAB, if more transactions exist than
selected, prompt the user to choose between renaming all transactions
or just the selected ones.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
When renaming a merchant in YNAB, prompt the user if the batch update
would affect more transactions than they have selected:
- "Rename all N" - Use batch payee update (affects all transactions)
- "Rename selected N only" - Use individual transaction updates

Implementation:
- Add get_transaction_count_by_payee() to ynab_client.py
- Add get_transaction_count_by_merchant() to backend base class
- Create BatchScopeScreen modal for user choice
- Add check_batch_scope() to detect scope mismatches
- Add skip_batch_for parameter to commit_pending_edits()
- Integrate prompt flow into app.py commit workflow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
The feature has been implemented; the plan document is no longer needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
old_merchant_name: str,
new_merchant_name: str,
) -> Dict[str, Any]:
"""

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bulk transaction reassignment lacks atomic rollback (medium severity)

The _batch_reassign_transactions method fetches transactions and performs a batch update without transaction atomicity guarantees. If the batch update partially fails, some transactions may be reassigned while others remain with the old payee, creating inconsistent state. Consider implementing validation to check update success and providing rollback capability or at minimum detailed failure reporting.


Automated security review by Claude 4.5 Sonnet - Human review still required

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my testing, I had the bulk transaction reassignment fail.

From Claude's interpretation of the logs (enter cyborg voice):

  Key Findings

  1. The batch operation failed entirely (not partially):
  PATCH /v1/budgets/.../transactions HTTP/1.1" 400
  detail='date must not be in the future or over 5 years ago (index: 0), date must not be in the future or over 5 years ago (index: 1)'
  The YNAB API rejected the entire batch with a 400 error, indicating all-or-nothing behavior - it validated all transactions before applying any. The error even includes indices (0, 1) showing which
  transactions in the batch had issues.

  2. Fallback to individual updates worked correctly:
  - Batch failed → fell back to individual updates (as designed)
  - 4 of 6 transactions succeeded individually (HTTP 200)
  - 2 failed with the same date error (transactions older than 5 years)

  3. The root cause is a YNAB constraint, not our code:
  date must not be in the future or over 5 years ago
  You have 2 transactions from "The Westchester Mall Park" that are older than 5 years. YNAB won't allow updating them at all.

  Regarding the Review Concern

  This actually addresses the atomicity concern - the logs show YNAB validates the entire batch before applying changes. The batch operation appears to be atomic (all-or-nothing), which is good. Our fallback
   mechanism then correctly handles partial success at the individual transaction level.

  The Real Bug

  The code should filter out transactions older than 5 years before attempting updates, or at least warn the user that some transactions can't be modified due to YNAB's date restrictions.

  Would you like me to add filtering for >5-year-old transactions?

(Exit cyborg voice) - we'll address the >5 year old transactions separately.

@github-actions
Copy link

🔒 Security Review: 1 High/Medium Issue Found

Claude's automated security review identified potential security concerns. Please review the inline comments.

Additionally:

  • 2 low severity issue(s) were skipped to reduce noise

Note: This is an automated review. False positives are possible. Please review each issue carefully and use your judgment.


Powered by Claude 4.5 Sonnet


assert updated["merchant"][0] == ""

def test_large_dataset_performance(self):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this change is ok.

@tlmaloney tlmaloney marked this pull request as ready for review December 10, 2025 01:52
Document batch renaming, payee merging, and duplicate payee handling
features for YNAB integration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
if not payee_result["payee"]:
return 0

transactions_api = ynab.TransactionsApi(self.api_client)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Batch transaction reassignment without user confirmation (medium severity)

The _batch_reassign_transactions method reassigns all transactions from one payee to another without explicit user confirmation in the method itself. While the caller may handle confirmation, this operation is destructive and affects all matching transactions. Ensure the caller (app.py) properly validates user intent before calling this method, and consider adding a docstring warning about the destructive nature of this operation.


Automated security review by Claude 4.5 Sonnet - Human review still required

@github-actions
Copy link

🔒 Security Review: 1 High/Medium Issue Found

Claude's automated security review identified potential security concerns. Please review the inline comments.

Additionally:

  • 1 low severity issue(s) were skipped to reduce noise

Note: This is an automated review. False positives are possible. Please review each issue carefully and use your judgment.


Powered by Claude 4.5 Sonnet

@wesm
Copy link
Owner

wesm commented Dec 10, 2025

@SamEdwardes would you be able to help with reviewing or testing? I might try to have Claude write some integration tests against my test account

wesm and others added 3 commits December 11, 2025 17:29
Add a framework for true e2e testing of YNAB backend operations against
a live test account. Tests verify the batch payee update functionality
added in this PR:

- Payee renaming with cascade to transactions
- Batch transaction reassignment (payee merging)
- Duplicate payee detection
- Transaction count by payee
- Cache invalidation after updates

Tests are skipped when YNAB_TEST_API_KEY is not set, so they don't
run in regular CI. Run with:
  export YNAB_TEST_API_KEY="your-token"
  uv run pytest integration_tests/ -v

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The integration tests were failing when the test budget had no accounts.
This commit improves the test setup process:

- Auto-create a test checking account if none exists in test budget
- Add better error messages for missing accounts
- Update README with clearer setup instructions
- Add troubleshooting section for common setup issues

This ensures integration tests run smoothly without manual account setup.
Added defensive check to prevent unsafe merges when the target payee
has duplicates. Previously, the code only checked for duplicates in
the source payee, but not the target.

Changes:
- Check target_payee_result["duplicates_found"] before merging
- Return error with method="duplicate_target_payees_found" if found
- Add integration test with mocking to verify the check works

This prevents transactions from being assigned to the wrong payee
when duplicate target payees exist (rare but possible via imports).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
"""

import os
import uuid

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 Hardcoded credentials from environment without validation (high severity)

The get_test_credentials() function loads YNAB_TEST_API_KEY from environment variables without any validation. If this integration test suite runs in CI/CD with credentials stored in secrets, a compromised secret or misconfigured environment could expose real API keys. Add validation to ensure test credentials are explicitly marked (e.g., require a prefix like 'test_') and never allow production keys.


Automated security review by Claude 4.5 Sonnet - Human review still required

@wesm
Copy link
Owner

wesm commented Dec 13, 2025

I added some integration tests with Claude. I'll merge this once the build is green, thank you!

)
wrapper = ynab.PostAccountWrapper(account=save_account)

response = accounts_api.create_account(budget_id=client.budget_id, data=wrapper)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Automatic account creation without user consent (medium severity)

The ynab_client fixture automatically creates a checking account (test_checking_account) if none exists, without explicit user confirmation. For integration tests against a live budget, automatically creating accounts could be unexpected. Consider requiring explicit setup or at minimum logging a prominent warning that an account will be created.


Automated security review by Claude 4.5 Sonnet - Human review still required

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, that's ok, this is the whole point of the integration tests to operate against a test account where it's ok to make destructive changes

old_merchant_name: Name of old payee (for logging)
new_merchant_name: Name of target payee (for logging)

Returns:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Batch transaction update without confirmation (medium severity)

The _batch_reassign_transactions method reassigns all transactions from one payee to another in a single batch operation. If the payee lookup logic has a bug or returns unexpected results, this could bulk-reassign many transactions incorrectly. The code lacks validation that old_payee_id and target_payee_id are different, potentially causing data corruption if they're the same.


Automated security review by Claude 4.5 Sonnet - Human review still required

@github-actions
Copy link

🔒 Security Review: 3 High/Medium Issues Found

Claude's automated security review identified potential security concerns. Please review the inline comments.

Additionally:

  • 2 low severity issue(s) were skipped to reduce noise

Note: This is an automated review. False positives are possible. Please review each issue carefully and use your judgment.


Powered by Claude 4.5 Sonnet

@wesm wesm changed the title Fix YNAB batch payee updates Fix YNAB batch payee updates, add YNAB integration tests Dec 13, 2025

- Each test creates transactions with a unique prefix (e.g., `__test_a1b2c3d4_`)
- Transactions created during tests are automatically deleted in cleanup
- Payees cannot be deleted via YNAB API, but test payees are easily identifiable
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've emailed YNAB suggesting to add delete functionality to their payees endpoint, fwiw.


# Refresh account cache to include the newly created account
client._fetch_and_cache_accounts()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 API key logged without redaction in test fixtures (high severity)

The print statement at line 95 logs the account ID directly without sanitization. While this is test code, API keys and identifiers should be redacted in logs to prevent accidental exposure. Use print(f"Created test account: {response.data.account.name[:10]}... (id={response.data.account.id[:8]}...)") to limit exposure in logs.


Automated security review by Claude 4.5 Sonnet - Human review still required


transactions_api = ynab.TransactionsApi(self.api_client)
response = transactions_api.get_transactions_by_payee(
budget_id=self.budget_id, payee_id=payee_result["payee"].id

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Transaction count query without pagination (medium severity)

The get_transaction_count_by_payee method calls get_transactions_by_payee without pagination limits. For payees with thousands of transactions, this could cause memory exhaustion or API timeouts. Add pagination or use a dedicated count endpoint if available from YNAB API.


Automated security review by Claude 4.5 Sonnet - Human review still required

@github-actions
Copy link

🔒 Security Review: 2 High/Medium Issues Found

Claude's automated security review identified potential security concerns. Please review the inline comments.

Additionally:

  • 2 low severity issue(s) were skipped to reduce noise

Note: This is an automated review. False positives are possible. Please review each issue carefully and use your judgment.


Powered by Claude 4.5 Sonnet

@wesm wesm merged commit 07bae21 into wesm:main Dec 15, 2025
9 checks passed
@SamEdwardes
Copy link

Thank you Wes and Thomas - I will report back if I have any issues! Apologies, I wasn't able to provide any help earlier.

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.

3 participants