One person, four contacts

An event platform for a nonprofit, where the same applicant existed several times over and nobody could tell which record was real.

Role

Contract developer on a small team, inherited codebase

Duration

June 2021 to February 2022

Stack

Laravel, Livewire, Alpine.js, Tailwind CSS, MySQL, Salesforce, FormAssembly, Authorize.Net

Scale

Several thousand users

What was wrong

The site held user accounts. Salesforce held contacts, and the marketing flows that ran off them. In principle each person was one account and one contact. In practice a single person could have several contacts, and the site often couldn't tell which one belonged to them.

None of it was a bug. Every cause was wiring or ordinary human behaviour:

  • Salesforce had more than one email field, and a form had been wired to write into the wrong one. A person's email would land somewhere the matching logic wasn't looking, so a new contact got created instead of an existing one being found.
  • People submitted a later form with a different email address than the one they'd used before.
  • The form platform could prefill a returning applicant's details, but only when it was wired to do so. Where it wasn't, an applicant retyping their own details slightly differently produced another record.

That's the failure mode worth noticing. Nothing threw an error. The system did exactly what it was configured to do, and the data drifted anyway.

Stopping the drift first

Fixing existing records is pointless while new ones are still being created wrongly, so the intake path went first.

Forms moved to FormAssembly, submitting to both Salesforce and to a receiving application I built on the site side, which normalised each submission into the site's own shape. One submission, two destinations, consistent from the start. The email field wiring was corrected so submissions landed where the matching logic would actually find them.

Payments moved at the same time. Card details had been taken over the phone into a point-of-sale terminal, with transaction details then entered by hand into the site and Salesforce — a manual step at exactly the point where mistakes are expensive. FormAssembly's Authorize.Net integration handled payment on submission for this site's event registrations, so the payment arrived with the record rather than being reattached to it afterwards.

Reconciling what was already broken

That left the records already in the system.

The obvious approach is a batch job: sweep everything, match by some rule, merge what looks the same. I didn't do that. Merging contacts is irreversible, the join key was demonstrably unreliable — that was the whole problem — and a bad merge across several thousand users is not something you unwind.

So the work happens per user, at login. Each account carries a flag for whether it has been reconciled. An unreconciled user gets a prompt on their next login asking to sync their data. They agree, it runs immediately, and the page refreshes with their details filled in.

For this audience that lands naturally. Anyone logging in is there to register for an event or check an application, so being asked to complete their own details is what they came to do.

Matching, when the identifier can't be trusted

The sync searches Salesforce for the user, and falls back as evidence gets weaker.

First by email, across every candidate field rather than just the expected one. Contacts had scattered across those fields — that's what created the duplicates — so searching all of them is what finds records the original matching missed.

When no email matched at all, which happens when Salesforce holds an address the person has since stopped using, it fell back to first name, last name and address together.

Everything found for that person is merged through Salesforce's native merge into a single surviving contact, with the most recent value winning where fields disagree. That contact is then associated with the Laravel account and fills in whatever the site was missing.

How one user was matched to one Salesforce contact An unreconciled user logs in and agrees to sync. The first tier searches Salesforce by email across every candidate email field, which is a strong match. If no email matches, a weaker second tier falls back to first name, last name and address together — this is where the accepted risk sits, because two people at one address sharing a full name would match. Whatever is found is merged through Salesforce's native merge, with the most recent value winning on conflicting fields, leaving one contact linked to the Laravel account with the site's missing data filled in. Unreconciled user logs in and agrees to sync 1 · Search by email across every candidate email field strong match match no email match 2 · Fall back to name and address first name, last name, address together weaker evidence — accepted risk Merge the duplicates Salesforce native merge · most recent value wins One contact, linked to the account missing site data filled from it
A merge cannot be undone. The dashed tier is where that risk sits.

The risk I accepted

The fallback tier is weaker evidence than the primary one. Email is close to unique. Name and address together are not — two people at one address sharing a full name is uncommon but real.

For that to cause a wrong merge, it would have to coincide with the email match having already failed. Two rare things at once, in a population of a few thousand. We accepted it.

I'd do it differently now. A merge can't be undone, so a low probability against an unrecoverable outcome deserves more than a probability argument — and the fix is cheap. Show the matched contact in the prompt and let the person confirm it's them. The prompt was already there. It just wasn't showing them what they were agreeing to.

Why it runs inline

The sync calls Salesforce during the request, with a spinner, and takes a few seconds.

The reflex is to queue that. I didn't, for two reasons. The page the user is about to see is built from the data being reconciled, so deferring the work means rendering a page you already know is about to change. And a queue would have meant workers, monitoring, failure handling and an intermediate state to explain to users — real infrastructure, on a site that otherwise needed none of it, for an operation each person performs exactly once.

If it ran on every request, or took long enough to look broken, that calculation flips. It ran once per user and finished in seconds.

Where it ended up

New submissions write to both systems consistently. Existing accounts reconcile themselves as people log in. Event payments are captured with the registration rather than keyed in afterwards.

The engagement ran eight months.