---
name: evolution_exo_import_xero_ref_collision
description: invoices.externalRef/externalTenant belong to Xero — importers must use the generic invoices.sourceRef column instead
metadata: 
  node_type: memory
  type: project
  originSessionId: 041b82a6-ec1c-40d7-83fe-ce361d31bd97
  modified: 2026-07-30T04:32:49.462Z
---

`invoices.externalRef` + `externalTenant` are **owned by the Xero integration** — never write them from an importer or external-sync tool. `externalRef` = `serialize(['target'=>'xero','value'=>'<GUID>'])`, `externalTenant` = the Xero organisation id. Import provenance goes in **`invoices.sourceRef`** `varchar(100)` (`idx_sourceRef`), format `<SYSTEM>:<foreign id>` e.g. `MYOB_EXO:319920`. Added 2026-07-30 via `evolution/migrations/invoices_source_ref.sql`; `library/exoimport.php::ensureSchema()` also adds it inline (xero2.php-style idempotent ALTER) so a tenant that never ran the migration can't silently lose dedupe and re-import everything.

**Why:** the EXO importer stored its dedupe key as `externalRef=<EXO id>, externalTenant='MYOB_EXO'`. Three separate paths read those columns as Xero state:
- `library/accounting.php::pushInvoiceToXero()` fires on `!empty($invoice->externalRef)` alone → pushed every imported invoice at commit time with `xeroTenantId='MYOB_EXO'`.
- `library/xero.php::putInvoice()` branches create-vs-update on `empty($inv->externalRef)` → takes the *update* branch and unserialises the EXO id as a Xero GUID.
- `cron/xero2.php` AR catch-up selects `externalRef = ''` → imported invoices could never be created in Xero at all.

The 2026-07-30 TIS prod load (tenant `304-1779936994`) flagged **1,762 invoices** `review_flag=1 / syncerror=1`, note "Xero sync failed: Could not create the customer in Xero. Forbidden AuthenticationUnsuccessful". Nothing reached Xero (`lastsync` all NULL), so no Xero-side cleanup. Note `companies.xerosync=0` does **not** stop this — it only gates the crons, not the real-time push in `commitInvoice()`.

**How to apply:** clearing `review_flag`/`syncerror` alone does not make these push — `externalRef` must also be blanked (that's what makes xero2.php eligible), which is what the migration does after backfilling `sourceRef`. Emptying `externalRef` is also the root-cause fix: with it empty, commit-time push no longer fires. Optional hardening still OPEN: make `pushInvoiceToXero()`/`putInvoice()` require a genuinely serialized Xero ref plus a tenant present in `addonXeroTenants`. Before re-enabling `companies.xerosync=1`, note `xerosyncfrom` gates the push by date (TIS = 2026-06-01, so 1,355 of 1,762 push, 407 pre-cutoff stay unlinked), Xero invoice numbers become the **Evolution invoice id** (`putInvoice` does `setInvoiceNumber($inv->id)`), and the cron does 500/run. Related: [[tomago_exo_data_mapping]], [[tomago_exo_import]], [[evolution_xero_payment_externalref]], [[tis_project_location]].
