HomeTech Talk

One customer, five systems: entity resolution and survivorship for finance data

CRM, contracts, ERP, billing and KYC each have their own idea of who the customer is. How I model a canonical party, match records without guessing, pick survivorship rules per attribute, and prove the result is right.

· 9 min read

  • entity resolution
  • customer data
  • master data
  • dbt
  • finance data
On this page 9 sections

TL;DR

  • CRM, contracts, ERP, billing and KYC each count customers at a different grain. Pick one by accident and revenue by customer and concentration risk quietly depend on it.
  • Model the legal entity as dim_party, point every operational account at exactly one party with its match evidence, and keep group hierarchy in an effective-dated bridge.
  • Match in passes: hard identifiers first, then the cross-references systems already store, then fuzzy scores that go to a review queue and never merge on their own.
  • Survivorship is decided per attribute, with source precedence first and recency only as a tie-breaker, and the winning source stored next to every value.
  • One test catches fan-out, dropped accounts and broken mappings at once: revenue by party must reconcile to revenue by billing account every period.
Seven customer records from KYC, contracts, billing, ERP and CRM converge on two canonical parties, with one fuzzy match held back in a review queue.

Ask five systems at a financial firm how many customers you have and you will get five answers, all of them defensible. The CRM counts relationships. The contract repository counts signatures. The ERP counts bill-to records. The billing platform counts whoever generated activity. KYC, the know-your-customer onboarding system, counts verified legal persons. Nobody is wrong. They are each answering a different question with the same word.

That is fine until someone asks for revenue by customer, net revenue retention, or top-ten client concentration. Those numbers quietly depend on which definition you picked, and most teams pick by accident: whichever table the first analyst joined to. This post is how I build a canonical customer instead, and how I keep it honest once it exists.

Five definitions of “customer”

System What it calls a customer Grain What it is good at
CRM An account the sales team manages Relationship Segment, owner, pipeline
Contract repository A counterparty that signed something Legal entity Legal name, signed terms
ERP A bill-to record Billing account Billing address, payment terms
Billing / usage Whoever generated activity Platform account What was actually charged
KYC / onboarding A verified legal person or entity Legal entity Identity, jurisdiction, ownership

Two of these are legal entities, one is a relationship, and two are operational accounts. The mistake is to pick one of them and declare it the customer. The relationship grain is too coarse for revenue recognition, and the account grain is too fine for concentration risk. You need both, connected.

The model

I separate the real-world counterparty from the accounts that point at it, and keep the hierarchy in its own effective-dated table.

dim_party                    one row per real-world legal entity (stable party_id)
   ▲
   │  many-to-one
dim_customer_account         one row per account in each source system
   │                         (source_system, source_account_id) → party_id
   │                         + match_method, match_confidence, matched_at
   │
bridge_party_relationship    parent/subsidiary, managed-by, fund-of
                             effective_from / effective_to

dim_party is the legal entity, because that is the grain that signs contracts, passes KYC and carries credit and concentration risk. Every operational account in every system lands in dim_customer_account with exactly one party_id, plus the evidence for why. Groups, fund families and “this is really the same relationship” live in bridge_party_relationship, effective-dated, because corporate structures change and you will be asked what the group looked like last year.

One rule I hold firmly: every account gets a party, even before it is resolved. An unmatched account becomes a provisional singleton party with match_status = 'provisional'. That keeps the “exactly one party” invariant true at all times, so downstream joins never fan out or drop rows while a reviewer is still thinking.

Matching: deterministic first, fuzzy last

Matching runs in passes, and each pass only touches what the previous pass left unresolved.

The first pass uses hard keys: KYC identifiers, legal entity identifiers, tax or registration numbers. If two records share one of those, they are the same party, full stop. The second pass uses cross-references the systems already store about each other: the ERP customer ID on the CRM account, the KYC ID on the billing account, the contract ID on the ERP record. These are human-entered and occasionally wrong, but they were entered by someone who knew the client.

Only then does fuzzy matching run: normalized names, addresses, domains, with a confidence score. In finance, fuzzy output goes to a review queue. It never merges on its own. A false merge combines two clients’ revenue, two clients’ exposure, and occasionally two clients’ statements. A false split just leaves a duplicate for a reviewer to catch. The costs are not symmetric, so the thresholds should not be either.

A worked example: Harbor Lane

Here is a toy client at a fictional custodian. Seven records across five systems:

Source ID Name as recorded Evidence
KYC kyc_7731 Harbor Lane Capital Management LP Registration no. on file
KYC kyc_9014 Harbor Lane Fund II LP Parent = kyc_7731
Contracts ctr_0192 Harbor Lane Capital Management, L.P. kyc_id = kyc_7731
Billing plat_201 harborlane-ops kyc_id = kyc_7731, erp_id = cust_4410
Billing plat_202 HL Fund II kyc_id = kyc_9014
ERP cust_4410 Harbor Lane Cap Mgmt none
CRM acc_88A Harbor Lane Capital erp_id = cust_4410

Pass one creates two parties from the KYC records: P1 for the management company and P2 for the fund. The contract and plat_201 attach to P1 on kyc_7731; plat_202 attaches to P2 on kyc_9014. Pass two follows cross-references: plat_201 carries cust_4410, so the ERP record joins P1, and the CRM account carries the same ERP ID, so it joins P1 too. The KYC parent pointer becomes a row in bridge_party_relationship: P2 is a fund of P1.

Now say the CRM also has a stray account, acc_93C, named “Harbor Lane Fund 2”, with no IDs at all. Fuzzy matching scores it 0.86 against P2. It goes to the review queue, not into P2.

Here is why this matters. The fictional custodian billed plat_201 $42,000 and plat_202 $18,000 last quarter, out of $1.2M total revenue. Reported by CRM account, the old way, Harbor Lane shows $42,000, and the fund’s $18,000 lands in an “unmapped” bucket that nobody looks at. Reported by party and rolled up through the bridge, the Harbor Lane group is $60,000: 5.0% of revenue instead of 3.5%. Same data, a different answer to “who are our largest clients”, and the concentration report is the one the board reads.

Seven records resolve to two parties; one stray goes to reviewSource recordsCanonical partieskyc_7731KYCHarbor Lane Capital Management LPctr_0192ContractsHarbor Lane Capital Management, L.P.plat_201Billingharborlane-opscust_4410ERPHarbor Lane Cap Mgmtacc_88ACRMHarbor Lane Capitalkyc_9014KYCHarbor Lane Fund II LPplat_202BillingHL Fund IIacc_93CCRMHarbor Lane Fund 2 (no IDs)P1Harbor Lane Capital Management LPbilled $42,000 last quarterfund of (bridge table)P2 · Harbor Lane Fund II LPbilled $18,000 last quarterReview queuefuzzy match to P2 (0.86), not mergedPass 1: shared hard key (KYC ID)Pass 2: stored cross-reference (ERP ID)Fuzzy: review onlyBy party, the group is $60,000, 5.0% of revenue. By CRM account it showed $42,000, or 3.5%.
Harbor Lane, the toy client: deterministic passes attach records to parties, and fuzzy matches only reach a review queue.

Survivorship, per attribute

Once records are matched, each party still has several candidate values for every attribute. Survivorship is the rule for which value wins. Picking one source to win everything is the usual shortcut, and it is wrong: KYC is the authority on legal name and jurisdiction, but it knows nothing about sales segment. So I declare precedence per attribute:

# Which source wins, attribute by attribute. First listed = most trusted.
legal_name:      [kyc, contracts, erp, crm]
billing_address: [erp, contracts, crm]
segment:         [crm]
industry:        [crm, kyc]
country_of_risk: [kyc]

The tie-breakers are precedence, then recency, then completeness. Sources not listed for an attribute are not allowed to contribute at all, which is deliberate: I do not want a CRM free-text field deciding a client’s country of risk.

For Harbor Lane, legal_name comes from KYC (“Harbor Lane Capital Management LP”), not the CRM’s friendlier “Harbor Lane Capital”. billing_address comes from the ERP, updated in February, even though the contract still has the address from signing. segment comes from the CRM because nothing else has an opinion.

In dbt, the rules become a seed with one row per attribute and source, and the golden record (the single winning version of each party) is a ranked pick:

-- One winning value per party × attribute, with the evidence kept.
with candidates as (
  select
    c.party_id,
    c.attribute,
    c.value,
    c.source_system,
    c.source_updated_at,
    r.precedence                      -- 1 = most trusted
  from {{ ref('int_party_attribute_candidates') }} c
  join {{ ref('survivorship_rules') }} r
    on r.attribute = c.attribute
   and r.source_system = c.source_system
  where c.value is not null
    and trim(c.value) != ''
)
select
  party_id,
  attribute,
  value,
  source_system      as won_from,
  source_updated_at  as won_at
from candidates
qualify row_number() over (
  partition by party_id, attribute
  order by precedence, source_updated_at desc, length(value) desc
) = 1

The won_from and won_at columns are the point. When someone asks why a client’s name looks odd on a report, the answer is a lookup, not an investigation. The empty-string filter matters too: a blank field in the most trusted source should not beat a real value from the next one.

What goes wrong in practice

Transitive blobs. If A matches B on address and B matches C on phone number, clustering puts A and C together. In finance, many unrelated entities share a registered agent’s address or a fund administrator’s phone line. One shared address can collapse dozens of funds into one party. Hard keys can be transitive; fuzzy evidence should never be.

Party ID churn. If you re-cluster from scratch every night, IDs shift, and every downstream snapshot, saved report and CRM sync breaks. Party IDs must be stable. When two parties merge, keep the survivor and record the retired ID in a merge map so old references still resolve.

Fan-out in revenue. If an account ever maps to two parties, every join through it double-counts. This is the most common way a well-intentioned customer model makes revenue go up.

Flapping attributes. Recency as a primary rule lets a noisy, low-trust source overwrite a good value every time it syncs. Recency is a tie-breaker inside a precedence level, not a replacement for it.

Hierarchy without dates. When a client acquires another, last year’s concentration report should not silently change. Effective-dating the bridge lets you answer both “what was the group then” and “what is it now”, which is the same as-of versus as-was distinction I wrote about in bitemporal modeling.

Tests that keep it honest

The model is only as good as the invariants you enforce on it every run. Every operational account maps to exactly one party. No party is orphaned without at least one account. No party exists without a legal name from an allowed source. And the one I care about most: revenue by party reconciles to revenue by billing account, in total, for every period.

-- Fails (returns rows) if any period's revenue changes when re-keyed by party.
with by_account as (
  select period_month, sum(revenue_usd) as usd
  from {{ ref('fct_revenue') }}
  group by 1
),
by_party as (
  select r.period_month, sum(r.revenue_usd) as usd
  from {{ ref('fct_revenue') }} r
  join {{ ref('dim_customer_account') }} a
    on a.source_system = 'billing'
   and a.source_account_id = r.billing_account_id
  group by 1
)
select a.period_month, a.usd as account_usd, p.usd as party_usd
from by_account a
left join by_party p using (period_month)
where p.usd is null
   or abs(a.usd - p.usd) > 0.01

This one test catches fan-out, dropped accounts and broken mappings at once. It is the same idea as in a ledger-first architecture: reconciliation is a test, not a quarterly project.

Beyond pass/fail tests, I monitor match rate by source and the age of the review backlog as metrics with owners. A backlog that grows quietly is how a clean model becomes a stale one.

Where AI helps, and where it must not

Language models are good at the tedious middle of this work. Given two candidate records, a model can propose a match and explain its reasoning in a sentence: “same registration number format, fund name matches the parent’s naming pattern, different address because one is the administrator.” A reviewer can then accept or reject it in seconds. That turns a review queue from a chore into a skim.

What it must not do is merge parties on its own. A merge changes revenue attribution, exposure and client statements. Those decisions need a named human and an audit trail, and the model’s suggestion should be stored as evidence alongside the decision, not as the decision.

The customer is a decision

There is no customer table waiting to be discovered in your source systems. There are five partial views and a set of choices about how to combine them: the grain, the matching evidence you trust, which source wins each attribute, and how history is kept. Make those choices explicit, store the evidence next to every value, and test the reconciliation every run. Then “revenue by customer” becomes a number you can defend line by line, and the next time two reports disagree, the argument is about a rule rather than a guess.