[GH-ISSUE #7855] [Bug]: Bank-sync reconciliation silently merges new imports into split-children of unrelated transactions when amount + ±7-day window match #100629

Closed
opened 2026-05-29 20:59:35 -05:00 by GiteaMirror · 5 comments
Owner

Originally created by @csantiviago on GitHub (May 15, 2026).
Original GitHub issue: https://github.com/actualbudget/actual/issues/7855

What happened?

(please note this was generated by Claude Code after debugging my specific issue)

A new bank-sync transaction is silently absorbed by a pre-existing split-child of an unrelated parent transaction, when the child has the same amount and a date within ±7 days, even when payees differ. The imported transaction never appears on its real date; the split it was merged into is left structurally inconsistent.

What I was trying to do: Run bank sync for an Brazilian account (Pluggy provider, self-hosted). A R$ 600,00 PIX received on 2026-05-15 from sender "Sender" was returned by Pluggy with transactionId=ffcfae43-..., date=2026-05-15, payeeName="Sender".

Expected: A new R$ 600,00 transaction added on 2026-05-15 with payee "Sender".

Actual: No new transaction. Instead, an existing split-child of an unrelated R$ 800,00 parent from 2026-05-08 (payee "Split", child notes "Reason X", imported_id IS NULL) was updated with the new transaction's imported_id and raw_synced_data — but its date, amount, payee were preserved. The imported transaction is invisible at its real date. The split remains balanced until the user attempts any partial update on the now-mismatched child (see "Additional note" below), at which point the child's amount is zeroed.

Root cause (read from @actual-app/api 26.5.0 dist/index.js):

matchTransactions (around line 111632) runs three reconciliation steps:

  1. Exact imported_id match.
  2. Same amount + same payee + date within ±7 days.
  3. Same amount + date within ±7 days, payee ignored (line 111701, transactionsStep3).

Step 3 falls back to any unmatched row in v_transactions with matching amount and date window. The candidate set is not filtered by parent_id IS NULL, so split-children are valid match targets. When the match is applied (around line 111546), the updates object includes imported_id, imported_payee, notes, cleared, raw_synced_data — but not amount, payee, or date (unless the per-account sync-update-dates preference is on).

How can we reproduce the issue?

  1. In any account, create a transaction A: date=2026-05-01, amount=$800, payee="Alice".
  2. Split A into two children:
    - $600 with category "Reimbursements" and notes "Plan X"
    - $200 with category "Reimbursements" and notes "Other"
    - Neither child has an imported_id.
  3. Wait until 2026-05-08 (or fake the system date). Trigger bank sync for the same account.
  4. Have the bank-sync provider return a new, unrelated transaction: date=2026-05-08, amount=$600, payee="Bob", transactionId=xyz-....
  5. Expected: A new $600 transaction is added on 2026-05-08 with payee "Bob".
  6. Actual: No new transaction is added. The $600 split-child of A is mutated:
    - imported_id set to xyz-...
    - imported_payee set to "Bob"
    - raw_synced_data set to Bob's full payload (which contains "date":"2026-05-08", "payeeName":"Bob")
    - date, amount, payee unchanged → still 2026-05-01 / $600 / Alice
  7. Bob's 600 transaction is now invisible in the register on 2026-05-08. The split appears balanced in the UI, but its 600 child silently represents Bob's transfer, not Alice's split intent.

Where are you hosting Actual?

Other

What browsers are you seeing the problem on?

Firefox

Operating System

Linux

Originally created by @csantiviago on GitHub (May 15, 2026). Original GitHub issue: https://github.com/actualbudget/actual/issues/7855 ### What happened? _(please note this was generated by Claude Code after debugging my specific issue)_ A new bank-sync transaction is silently absorbed by a pre-existing split-child of an unrelated parent transaction, when the child has the same amount and a date within ±7 days, even when payees differ. The imported transaction never appears on its real date; the split it was merged into is left structurally inconsistent. What I was trying to do: Run bank sync for an Brazilian account (Pluggy provider, self-hosted). A R$ 600,00 PIX received on 2026-05-15 from sender "Sender" was returned by Pluggy with transactionId=ffcfae43-..., date=2026-05-15, payeeName="Sender". Expected: A new R$ 600,00 transaction added on 2026-05-15 with payee "Sender". Actual: No new transaction. Instead, an existing split-child of an unrelated R$ 800,00 parent from 2026-05-08 (payee "Split", child notes "Reason X", imported_id IS NULL) was updated with the new transaction's imported_id and raw_synced_data — but its date, amount, payee were preserved. The imported transaction is invisible at its real date. The split remains balanced until the user attempts any partial update on the now-mismatched child (see "Additional note" below), at which point the child's amount is zeroed. Root cause (read from @actual-app/api 26.5.0 dist/index.js): matchTransactions (around line 111632) runs three reconciliation steps: 1. Exact imported_id match. 2. Same amount + same payee + date within ±7 days. 3. Same amount + date within ±7 days, payee ignored (line 111701, transactionsStep3). Step 3 falls back to any unmatched row in v_transactions with matching amount and date window. The candidate set is not filtered by parent_id IS NULL, so split-children are valid match targets. When the match is applied (around line 111546), the updates object includes imported_id, imported_payee, notes, cleared, raw_synced_data — but not amount, payee, or date (unless the per-account sync-update-dates preference is on). ### How can we reproduce the issue? 1. In any account, create a transaction A: date=2026-05-01, amount=$800, payee="Alice". 2. Split A into two children: - $600 with category "Reimbursements" and notes "Plan X" - $200 with category "Reimbursements" and notes "Other" - Neither child has an imported_id. 3. Wait until 2026-05-08 (or fake the system date). Trigger bank sync for the same account. 4. Have the bank-sync provider return a new, unrelated transaction: date=2026-05-08, amount=$600, payee="Bob", transactionId=xyz-.... 5. Expected: A new $600 transaction is added on 2026-05-08 with payee "Bob". 6. Actual: No new transaction is added. The $600 split-child of A is mutated: - imported_id set to xyz-... - imported_payee set to "Bob" - raw_synced_data set to Bob's full payload (which contains "date":"2026-05-08", "payeeName":"Bob") - date, amount, payee unchanged → still 2026-05-01 / $600 / Alice 7. Bob's $ 600 transaction is now invisible in the register on 2026-05-08. The split appears balanced in the UI, but its $ 600 child silently represents Bob's transfer, not Alice's split intent. ### Where are you hosting Actual? Other ### What browsers are you seeing the problem on? Firefox ### Operating System Linux
GiteaMirror added the bugneeds info labels 2026-05-29 20:59:36 -05:00
Author
Owner

@coderabbitai[bot] commented on GitHub (May 15, 2026):

⚠️ Possible Duplicate Issue(s)

📝 Issue Planner

Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

  • Create Plan

🧪 Issue enrichment is currently in open beta.

You can configure auto-planning by selecting labels in the issue_enrichment configuration.

To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

issue_enrichment:
  auto_enrich:
    enabled: false

💬 Have feedback or questions? Drop into our discord!

<!-- gh-comment-id:4463104937 --> @coderabbitai[bot] commented on GitHub (May 15, 2026): <!-- This is an auto-generated issue plan by CodeRabbit --> <details> <summary>⚠️ Possible Duplicate Issue(s)</summary> - https://github.com/actualbudget/actual/issues/6737 </details> --- <details> <summary>📝 Issue Planner</summary> <sub>Check the box below or use the `@coderabbitai plan` command to generate an implementation plan and prompts that you can use with your favorite coding assistant.</sub> - [ ] <!-- {"checkboxId": "8d4f2b9c-3e1a-4f7c-a9b2-d5e8f1c4a7b9"} --> Create Plan </details> --- <details> <summary> 🧪 Issue enrichment is currently in open beta.</summary> You can configure auto-planning by selecting labels in the issue_enrichment configuration. To disable automatic issue enrichment, add the following to your `.coderabbit.yaml`: ```yaml issue_enrichment: auto_enrich: enabled: false ``` </details> 💬 Have feedback or questions? Drop into our [discord](https://discord.gg/coderabbit)!
Author
Owner

@MatissJanis commented on GitHub (May 15, 2026):

beep boop, I am an LLM automation running on behalf of Matiss

Root-cause analysis (automated triage)

Symptom: During bank sync, an incoming transaction is silently "matched" against a split-child of an unrelated parent transaction, so the imported transaction never appears in the register.

Likely root cause:
The fuzzy-matching SQL query in matchTransactions (packages/loot-core/src/server/accounts/sync.ts:819–823) reads from v_transactions with only date >= ? AND date <= ? AND amount = ? AND account = ? — there is no parent_id IS NULL or is_child = 0 guard. The v_transactions view (defined in packages/loot-core/migrations/1608652596044_trans_views.sql:39–42) includes every row: parents, regular transactions, and split children. Step 3 of the fuzzy match (lines 880–888) then calls fuzzyDataset.find(row => !hasMatched.has(row.id)) — the first unmatched row wins, regardless of whether it is a child. Once the child is selected as a match, the reconcile loop (lines 569–620) writes imported_id, imported_payee, notes, cleared, and raw_synced_data onto the child, marking the incoming transaction as "already handled." The child's date, amount, and payee are left unchanged, so the parent split stays superficially balanced while the real bank transaction is invisible.

Regression: Unable to determine
The available local git history for sync.ts only goes back to the Enable Banking integration commit (0fd510a). The fuzzy-matching logic and the unfiltered v_transactions query appear to have been present before this window; no commit in the visible history modifies the fuzzyDataset query to add or remove an is_child filter. This looks like a longstanding design gap rather than a recent regression, but a full git log of the file on the upstream repo would be needed to confirm.

Confidence: High
The code path was traced end-to-end: query → step-3 picker → reconcile writer. The only reason a split child would appear in fuzzyDataset is the missing is_child = 0 / parent_id IS NULL predicate, which is confirmed absent in both branches of the fuzzy-dataset query (lines 787–823).

Open questions for the reporter (if any):

  • Does step 2 (payee-match fuzzy pass, lines 861–873) ever produce the bad match in your case, or is it always step 3? Knowing which pass triggers it would help scope any fix.
  • Does the problem also occur when the split child has an explicit payee set (rather than inheriting the parent's payee)?

This is an automated root-cause analysis. It does not propose a fix. A maintainer will review and decide next steps.


Generated by Claude Code

<!-- gh-comment-id:4463142955 --> @MatissJanis commented on GitHub (May 15, 2026): > beep boop, I am an LLM automation running on behalf of Matiss ## Root-cause analysis (automated triage) **Symptom:** During bank sync, an incoming transaction is silently "matched" against a split-child of an unrelated parent transaction, so the imported transaction never appears in the register. **Likely root cause:** The fuzzy-matching SQL query in `matchTransactions` (`packages/loot-core/src/server/accounts/sync.ts:819–823`) reads from `v_transactions` with only `date >= ? AND date <= ? AND amount = ? AND account = ?` — there is no `parent_id IS NULL` or `is_child = 0` guard. The `v_transactions` view (defined in `packages/loot-core/migrations/1608652596044_trans_views.sql:39–42`) includes every row: parents, regular transactions, **and split children**. Step 3 of the fuzzy match (lines 880–888) then calls `fuzzyDataset.find(row => !hasMatched.has(row.id))` — the first unmatched row wins, regardless of whether it is a child. Once the child is selected as a match, the reconcile loop (lines 569–620) writes `imported_id`, `imported_payee`, `notes`, `cleared`, and `raw_synced_data` onto the child, marking the incoming transaction as "already handled." The child's `date`, `amount`, and `payee` are left unchanged, so the parent split stays superficially balanced while the real bank transaction is invisible. **Regression:** Unable to determine The available local git history for `sync.ts` only goes back to the Enable Banking integration commit (`0fd510a`). The fuzzy-matching logic and the unfiltered `v_transactions` query appear to have been present before this window; no commit in the visible history modifies the `fuzzyDataset` query to add or remove an `is_child` filter. This looks like a longstanding design gap rather than a recent regression, but a full `git log` of the file on the upstream repo would be needed to confirm. **Confidence:** High The code path was traced end-to-end: query → step-3 picker → reconcile writer. The only reason a split child would appear in `fuzzyDataset` is the missing `is_child = 0` / `parent_id IS NULL` predicate, which is confirmed absent in both branches of the fuzzy-dataset query (lines 787–823). **Open questions for the reporter (if any):** - Does step 2 (payee-match fuzzy pass, lines 861–873) ever produce the bad match in your case, or is it always step 3? Knowing which pass triggers it would help scope any fix. - Does the problem also occur when the split child has an explicit payee set (rather than inheriting the parent's payee)? --- *This is an automated root-cause analysis. It does not propose a fix. A maintainer will review and decide next steps.* --- _Generated by [Claude Code](https://claude.ai/code)_
Author
Owner

@youngcw commented on GitHub (May 17, 2026):

Including split transaction in the bank sync matching was added on puprpose. Its useful for matching fees with transactions such as wire fees. Also some users compress credit card statements into a single split.

Do you expect to continually have some transactions that aren't included in the bank sync? I don't see this being a problem outside of the initial sync.

<!-- gh-comment-id:4472109940 --> @youngcw commented on GitHub (May 17, 2026): Including split transaction in the bank sync matching was added on puprpose. Its useful for matching fees with transactions such as wire fees. Also some users compress credit card statements into a single split. Do you expect to continually have some transactions that aren't included in the bank sync? I don't see this being a problem outside of the initial sync.
Author
Owner

@csantiviago commented on GitHub (May 17, 2026):

Hi, no I don’t. Just thought was something that might happen with others but it was a specific case.

<!-- gh-comment-id:4472506313 --> @csantiviago commented on GitHub (May 17, 2026): Hi, no I don’t. Just thought was something that might happen with others but it was a specific case.
Author
Owner

@youngcw commented on GitHub (May 17, 2026):

I think this is the first time I've seen any issue with matching splits. I guess you just happened to have something with the same amount within the matching window that wasn't already matched. Im glad you were able to track down your issue. Ill close this since the behavior is deliberate but if you have any more problems feels free to reach out.

<!-- gh-comment-id:4472530989 --> @youngcw commented on GitHub (May 17, 2026): I think this is the first time I've seen any issue with matching splits. I guess you just happened to have something with the same amount within the matching window that wasn't already matched. Im glad you were able to track down your issue. Ill close this since the behavior is deliberate but if you have any more problems feels free to reach out.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/actual#100629