Customer matching

TL;DR

CCC decides which orders belong to one customer by building fingerprints per order and grouping everything that shares one. The grouping is transitive, it is recomputed from scratch on every run, and a group's shape changes as orders and keys change.

NetSuite does not tell CCC which orders belong to the same person. A NetSuite customer record is created per checkout, so the same person placing three orders can hold three records. CCC works out the grouping itself: it builds a fingerprint per order on each of four identity axes, then joins every order that shares a fingerprint into one customer.

Grouping is transitive

Matching does not compare orders in pairs and stop. It builds connected groups: if order A shares a key with order B, and B shares a different key with C, then A, B and C are one customer, even though A and C have nothing in common.

That is the intended behaviour, and it is also the cause of over-grouping. One shared key, a family email address or a reused address, can pull together orders that belong to different people. The chain can be several links long, so the two orders at its ends may look entirely unrelated.

The four identity axes

Matching runs on four fingerprints, and which axis an order can offer depends on when it was imported:

AxisTableAvailable onCan be refused
NetSuite customer recordcustomerfingerprintnsentity2022 onward. salesorders.entity is NULL on historic ordersNever, it is NetSuite's own answer
Webshop accountcustomerfingerprintaccount2022 onward where a linked ns_customers.externalid supplies an accountNever, it is the webshop's own answer
EmailcustomerfingerprintemailEffectively every order, and the only axis historic orders haveYes, through the exclusion list
Address and namecustomerfingerprintaddressWhere the order carries a surname or company name, so not on historic ordersYes, through the exclusion list

Identity on a pre-2022 order rests on email alone. That is expected data rather than an error, and there is no special handling for it. It also means historic identity is weaker than recent identity, which matters when comparing a customer count across the 2022 boundary.

The entity and account tables have no excluded column at all, so a match on one of those cannot be suppressed.

How the address key is built

The address components are concatenated in this order: country, postcode, house number, house-number addition, line 1, line 2, city and state. addressnamecombi appends surname, then company name. The result is lowercased and stripped to letters and digits. addresscombi omits the names and is the key used by the exclusion list.

A missing surname and company name removes the address matching axis. A key exceeding the stored length is refused rather than truncated. Email is trimmed, lowercased and bounded to its stored length. Matching uses equality under the database collation, not fuzzy spelling or geographical matching. The key construction is defined in app/models/customers.php; changing it changes which orders connect.

The name is in the key deliberately. Without it the key identifies a dwelling, and a dwelling outlives its occupants.

The exclusion list

Nothing is refused automatically, however many records a key joins. The only control is config/dictionary/customerexclusions.json, read at run time. Every run prints its top 20 keys by record count, which is how a new internal address or placeholder email becomes visible to a person.

Two rules govern what belongs on it:

  • An address is listed once per key shape, because the two importers build the key differently. A live NetSuite order fills both addresshousenumber and addressline1, so the house number appears twice; the historic importer leaves addresshousenumber empty and it appears once. List one shape only and the address is refused on one import path, with nothing to report the gap.
  • A key carried by a single NetSuite record is never listed, because it merges nothing. That is what keeps the list finite: one address can appear in many single-record variants with corrupted house numbers, and listing them all would achieve nothing.

A staff member's email address is deliberately not listed. It belongs to a real person.

How a group changes later

A customer is a computed group, not a record someone created. customers_reconcile rebuilds the grouping from the fingerprints every night, so the answer is a function of the data on the day it ran. These are the things that change it.

A new order arrives carrying a key both groups already had. Two customers who were separate become one. This is the common case: a person who ordered under a webshop account and later ordered as a guest with the same email is two groups until an order carries both.

A customer moves house, or changes email. The new order produces a new address or email fingerprint that matches nothing. If no other axis connects it to the previous orders, it starts its own group, and the person's history splits in two. If the NetSuite entity or the webshop account is the same, that axis holds the group together and the move changes nothing.

An exclusion is added. A key on the exclusion list stops joining anything, so the orders it was holding together separate, unless another key still connects them. Adding the office address, for example, breaks a group of unrelated orders that were all delivered to the office.

An address is corrected upstream. The key is built from the address fields, so fixing a typo'd postcode changes the key. Orders that matched on the typo no longer match on the corrected value, and orders that carried the correct value now do.

The historic import runs. Pre-2022 orders arrive carrying email only, so they attach to whatever group already holds that email address and cannot attach on any other axis. A backfill can extend or merge existing groups, and creates new groups where no key matches.

Two consequences worth holding onto:

Adding an exclusion may not split a group. Where two orders are connected through more than one key, removing one leaves the others holding the group together. Splitting requires breaking every path between them, not the most obvious one.

Cancelled and closed orders still belong. They keep their membership even though they are not counted for sequence or life. Membership and counting are separate questions, covered in customer sequence and life.

What a change of shape does to the figures already published is merges and splits.

Reconciliation rebuilds rather than patches

The grouping is recomputed rather than incrementally edited, and it writes in place. Two consequences for anyone reading the tables while it runs:

  • there is no snapshot-consistent read during a reconcile. A query part-way through can see a mixture of old and new grouping
  • customer membership changes; absorbed ids remain as pointers. Anchor selection is covered in merges and splits

Evidence

Connected-group construction from app/models/customers.php::setworknodes(), setunion() and setgroups(). Uncounted statuses from customers.php::$uncountedstatuses, which is C and H. In-place reconciliation and non-atomic claiming from the customer job and app/models/batches.php. Basis: code-checked against the current implementation.

The four fingerprint families and their exclusion support are the four customerfingerprint* tables, of which only email and address carry an excluded column. Transitive grouping is the intended definition of one customer, confirmed by the data owner.