From 543464700263c10b504ec397bf09d631c877c08c Mon Sep 17 00:00:00 2001 From: Corrin Lakeland Date: Sat, 1 Aug 2026 22:25:11 +1200 Subject: [PATCH] fix: skip Xero phone sync for merged companies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A merged company's numbers live on the company it was merged into, but its archived Xero contact keeps the phones and is still fetched with include_archived=True. Every sync then collides with the winner's rows under the one-number-one-company rule, hard-failing the company and persisting an AppError — 105 of them in production. No prod-only remedy exists: blanking the number gets re-synced from Xero, and merging in Xero doesn't strip the archived contact's phones. sync_xero_phone_methods now returns early for a merged company; the next sync clears the whole backlog at once. A test pins the exact collision scenario. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PnuHL7w3UisSk6KQ81q3Qf --- apps/workflow/api/xero/reprocess_xero.py | 6 ++++++ apps/workflow/tests/test_sync_clients.py | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/apps/workflow/api/xero/reprocess_xero.py b/apps/workflow/api/xero/reprocess_xero.py index 7e4701f3e..2d9d8f472 100644 --- a/apps/workflow/api/xero/reprocess_xero.py +++ b/apps/workflow/api/xero/reprocess_xero.py @@ -47,6 +47,12 @@ def sync_xero_phone_methods(company: Company) -> list[str]: Returns the normalized numbers that were newly created, so the caller can dispatch a call rematch for them. """ + # A merged company's numbers live on the company it was merged into. Its + # archived Xero contact keeps the phones (and is still fetched with + # include_archived=True), so syncing it would collide with the winner's + # rows under the one-number-one-company rule. + if company.merged_into_id: + return [] phones = company.raw_json.get("_phones", []) if company.raw_json else [] if not isinstance(phones, list): return [] diff --git a/apps/workflow/tests/test_sync_clients.py b/apps/workflow/tests/test_sync_clients.py index fba52e6ab..6cbfdc218 100644 --- a/apps/workflow/tests/test_sync_clients.py +++ b/apps/workflow/tests/test_sync_clients.py @@ -345,6 +345,28 @@ def test_duplicate_phone_owner_crashes_sync_and_persists_app_error(self) -> None self.assertIn("+6421555123", app_error.message) self.assertIn("Existing Phone Owner", app_error.message) + def test_merged_company_phones_are_not_synced(self) -> None: + """A merged company's archived Xero contact keeps its phones, but they + now belong to the winner — syncing must skip, not collide.""" + winner = Company.objects.create( + name="Winner", xero_last_modified=timezone.now() + ) + ContactMethod.objects.create( + company=winner, + method_type=ContactMethod.MethodType.PHONE, + value="021 555 123", + ) + merged = self._client_with_phone("Merged Loser") + merged.merged_into = winner + merged.save() + before = AppError.objects.count() + + created = sync_xero_phone_methods(merged) # must not raise + + self.assertEqual(created, []) + self.assertEqual(ContactMethod.objects.filter(company=merged).count(), 0) + self.assertEqual(AppError.objects.count(), before) + def test_resync_of_existing_number_is_grandfathered(self) -> None: """Re-syncing a company's own already-stored number must not raise.""" owner = self._client_with_phone("Phone Owner")