Founders

How to Attribute a Metric to the Right System of Record

Antonio Evans9 min read

A split scene: on the left, three labeled boxes (Stripe, application database, CRM) each emit a card showing a different number for the same metric, for example 4,200, 4,150 and 4,380.
Key points

Key points

  1. 1Assign every reported metric exactly one owning system in a written precedence rule, and version that rule so a past decision can be replayed.
  2. 2Capture the disagreement at event time by storing each source's value side by side with the winning value rather than overwriting the loser.
  3. 3Treat source identity, rule version and comparison timestamp as first class columns so any row answers which source won and why without a search through chat history.
  4. 4Separate cash movement from recognized revenue before you pick a winner, because Stripe balance transactions, charge counts and ledger entries can all be correct at once.

Why three correct numbers look like a bug

Open Stripe, filter payments for last month, and read the count. Then count the rows your sync wrote into the warehouse for the same period. Then read the number your accounting system reports. The three differ, and the instinct is to hunt for duplicates.

Almost nothing is duplicated. Stripe's balance transactions are its recommended starting point for reporting on balance activity, and Stripe creates one for every transaction that comes into or flows out of the account balance, which includes payouts, refunds, disputes and reserve holds, not just customer charges per the Stripe reference. A charge is one kind of movement among many.

Then there is the fee. The processing fee on a card charge is not its own balance transaction. It is a field on the charge's own transaction, and net is computed as amount minus fee, as the balance transaction walkthrough explains when describing the amount, fee and net fields. Your general ledger wants revenue and fee as two facts in two accounts, so a tool that produces a usable fee expense line turns one Stripe row into at least two accounting events. A count roughly double the payment count is the expected shape of a healthy month, not a symptom, as the Acodei analysis of payment counts versus balance transactions lays out.

So the disagreement is real but structural. The question is not which number is right. It is which number is the metric, and what happens to the others.

That framing has a useful habit behind it: in a sales pipeline you define stages and a gate for each stage so a deal's position is unambiguous rather than a matter of opinion, which is how the OpenVC pipeline guide structures a raise. Metrics need the same discipline: a named state, a named owner, and a gate that says when the state changes.

Decide ownership per metric, not per system

The common failure is a system-level decision: "Stripe is the source of truth." That sentence breaks the first time you need to count trials, seats, or canceled subscriptions, none of which Stripe models the way your product does. The durable unit of governance is the metric.

Write a precedence rule as a table, one row per metric. Four columns are enough: the metric, the owning system, the fallback order across the remaining sources, and the time semantics (when the value is considered final).

MetricOwning systemWhy it ownsFallback orderFinal at
Cash collected in periodStripeOnly place where money actually movedbank statement, then ledgersettlement date
Gross bookingsApplication databaseRecords intent, including pending and failedStripe, then ledgerend of period
Net revenue (recognized)LedgerApplies deferral and contraction rulesapp database, then Stripeafter close
Active subscriptionsApplication databaseOwns plan state, trial and dunning logicStripe, then CRMhourly snapshot
Account owner and segmentCRMOwns the human relationship and territoryapp databaseon write
Pipeline stage of a dealCRMOwns the sales processapp databaseon write

Three decisions in that table are worth defending.

First, cash belongs to the processor because the processor is the only system that observed the money move. Gross bookings belong to the application database because it saw the customer's intent before the card was authorized, so it can represent failed and pending attempts. Recognized revenue belongs to the ledger because it is the only system applying accounting rules.

Second, the CRM owns relationship attributes, not financial ones. A CRM that reports revenue is reporting a number it copied from somewhere else, and copied numbers drift.

Third, "final at" matters as much as "owner." A number can be owned by Stripe and still be provisional until settlement. Stripe distinguishes pending from available balances and flips the status on the balance transaction as settlement completes according to the balance transaction types reference. If your rule does not say when a value stops changing, every dashboard argument becomes a timing argument in disguise.

For comparison and reporting metrics that investors will read (burn, runway, a single KPI shown consistently), the discipline is the same: name the metric, name who calculates it, and keep the definition fixed across periods, which is the pattern recommended in Carta's investor update guidance and in the Raisemoney update format.

Capture the conflict at event time, not in a monthly meeting

A reconciliation done in a spreadsheet at close is a reconstruction. A reconciliation done in the pipeline is a record. The difference is whether the losing value still exists next quarter.

The rule: never let a merge or an upsert overwrite another source's value for the same metric and period. Write one row per source per metric per period, then a decision row that names the winner.

A minimal schema:

create table metric_observation (
  metric_key       text        not null,  -- 'cash_collected', 'active_subscriptions'
  period_start     date        not null,
  period_end       date        not null,
  source_system    text        not null,  -- 'stripe', 'app_db', 'crm', 'ledger'
  source_value     numeric     not null,
  currency         text,
  observed_at      timestamptz not null,  -- when this source's value was read
  source_watermark timestamptz,           -- latest event time included in this value
  primary key (metric_key, period_start, period_end, source_system, observed_at)
);

create table metric_resolution (
  metric_key       text        not null,
  period_start     date        not null,
  period_end       date        not null,
  rule_version     text        not null,  -- 'precedence-v7'
  winning_source   text        not null,
  reported_value   numeric     not null,
  compared_at      timestamptz not null,
  observation_ids  uuid[]      not null,  -- the rows that competed
  decided_by       text        not null,  -- 'rule' or 'rule+manual_override'
  override_reason  text,
  primary key (metric_key, period_start, period_end, compared_at)
);

Two design choices carry most of the value. source_watermark records how much of the source data the value included, so a comparison between a Stripe figure current through Tuesday and a warehouse figure current through Monday is visibly unfair rather than mysteriously wrong. rule_version turns the precedence table into a versioned object, so a value reported last quarter can be re-explained with the rule that was in force then, not today's rule.

The same idea appears in warehouse modeling as slowly changing dimensions: when an attribute's history matters for analysis, you keep multiple rows with effective dates rather than overwriting in place, which is the distinction between SCD Type 1 and Type 2 laid out in the slowly changing dimensions implementation guide and in Denodo's modeling notes. A precedence rule is a Type 2 attribute: it changes, and the old versions must survive.

Make the pipeline arrive in a state you can replay

Capturing the conflict depends on the ingest being trustworthy, and ingest is where most attribution programs quietly fail.

Stripe pushes events to an HTTPS endpoint, and events can be delivered more than once and out of order, which is why the production pattern inserts the event into a table with a unique constraint on the event identifier before running any business logic, and returns success only after the row is committed as described in the Stripe webhook idempotency pattern. Stripe's own documentation covers registering endpoints and receiving JSON payloads over HTTPS in the webhooks guide, and the catalog of event types your handler must expect is in the API reference.

If you derive metrics by re-fetching the current object rather than trusting the payload, you get correct state, but you lose what the object looked like when the event fired. Those are different needs, and a metric attribution program needs both. Keep the raw event for replay and refetch the object for truth.

Stripe also warns that fee credits are not uniform across time: credits issued before a cutoff date generate no balance transactions and appear only in dashboard exports, while later ones appear as transactions with a balance_type of fee_credits per the fee credit support note. Translation for the precedence rule: a source can change its reporting behavior for a population of transactions. If your rule version does not change when that happens, you will attribute a shift in reporting mechanics to a shift in the business.

A checklist for the ingest layer:

CheckWhy it matters for attribution
Raw events stored with unique IDs before processingMakes the comparison reproducible after a deploy
Each source value stamped with an observed_atSeparates timing gaps from genuine disagreement
A watermark per source (latest included event)Stops you comparing Thursday's Stripe figure to Monday's warehouse figure
Rule version stored on every resolved metricLets an auditor replay the decision, not just read it
Manual overrides stored with a reason and ownerKeeps exceptions visible instead of silently baked in
A daily job that writes the comparison even when sources agreeAgreement is also a finding, and its absence is suspicious

That last row is the one teams skip. If you only write rows when sources disagree, you cannot distinguish no conflict from the comparison never ran.

A worked example with numbers

Say a subscription business closes a month and three systems report cash collected.

The application database says 1,000 successful charges with a gross total of 100,000. Stripe's payments view agrees: 1,000 charges. Stripe's balance transactions for the period include those charges plus a refund of 2,000, a partial capture adjustment, and a payout. The count of balance transactions is higher than 1,000 and the net balance impact is lower than 100,000. The ledger, receiving gross revenue of 100,000 and a fee expense of roughly 3,200, shows 100,000 in revenue and 96,800 net.

Nobody is wrong. The metric "cash collected" is owned by Stripe, so the reported figure is Stripe's net balance impact for the period, after fees and refunds. The application database's 100,000 is stored as a competing observation, flagged as gross, and labeled with the currency and the included charge count. The ledger's 96,800 is stored as recognized net. The resolution row says: metric cash_collected, winning source stripe, rule precedence-v7, compared at a specific timestamp, with the three observation identifiers attached.

Six months later, when the number is questioned, the auditor does not compare a number to a memory. They see the row: value, winner, rule, timestamp, and the losers preserved. Then they run the same comparison against the same observations and get the same answer. If the rule has changed since, they can see which version produced the figure and what changed in between.

Two more numbers make the failure mode concrete. If you overwrite rather than store, you keep one value per metric per period. If you store three sources, you keep three, and the cost is storage you can measure in kilobytes. The information difference is the entire audit trail. If a fee-credit cutoff shifts 400,000 of earlier credits from a balance transaction to a dashboard-only export, an overwrite design reports a 400,000 drop in fee activity with no way to explain it, while a stored-observation design shows the source's behavior changing under the same metric.

Exposing the resolution to whoever asks

The data model only pays off if a human or a machine can query it without reading a wiki.

Three access patterns cover almost every question:

  1. The value view, which returns one row per metric per period with reported_value, winning_source and rule_version. Every dashboard and every investor update reads this view, never the raw sources.
  2. The conflict view, which returns periods where the spread between the highest and lowest observation exceeds a threshold you set, with the sources named. Review it weekly; it is a better use of an hour than arguing about a number in a meeting.
  3. The replay query, which takes a metric, a period and a rule version, returns the observations as of the comparison timestamp, and re-applies the rule. If it disagrees with the stored resolution, something upstream changed without a rule change, which is exactly the thing you want flagged.

Two governance rules keep this honest. Any manual override must write to metric_resolution with a reason and an owner, never to metric_observation. And any change to the precedence table increments rule_version and is recorded with a date, so a question about why a number moved has two distinguishable answers: the business changed, or the rule changed.

For teams that need the reporting surface built around this model (an internal metrics view, an auditor-facing page, or an investor dashboard reading only the value view), that is the kind of build Quanta takes on with you, from the schema to the app.

FAQ

FAQ

If Stripe is the system of record, do I still need the ledger number?

Yes, for two reasons. Stripe reports money movement, while the ledger reports recognized revenue under your accounting rules, and those are different metrics with different owners. Storing both, labeled and versioned, means a revenue question and a cash question get answered from the same row rather than from two disputed dashboards, and you keep the ability to explain a restatement later.

What happens when a source changes its reporting behavior, as with fee credits?

Treat a change in how a source reports as a rule version change. Stripe documents that fee credits issued before a cutoff appear only in dashboard exports while later ones appear as balance transactions with a distinct balance type per the support note. If you bump rule_version and record the date, a step change in the metric is explainable. If you do not, it looks like a business event and pollutes your trend.

How do I choose the time semantics for final?

Pick the moment the owning system stops changing the value and make that a column. For cash that is settlement, since Stripe moves a balance transaction from pending to available on a settlement schedule it documents in the balance transaction reference. Metrics with different finality dates should not share a dashboard refresh schedule, or you will compare a settled number to a provisional one.

Does out-of-order webhook delivery affect attribution?

It affects the values you store, not the precedence rule. Because events can arrive twice or out of order, the ingest must deduplicate on the event identifier and derive state from the current object rather than the payload per the idempotency pattern. Once the observation rows are correct and watermarked, the precedence rule resolves them deterministically no matter what order they landed.

Who should own the precedence table itself?

Assign it to one named person, usually whoever owns the numbers investors see, with a required review step from finance if the metric touches revenue. The table is small, a dozen rows in most companies, and the failure mode of shared ownership is a rule that everyone assumes someone else updated. Version control it like code, and let the resolution rows point at the version rather than at a document.

Sources

Sources

  1. Balance transaction types
  2. Stripe API Balance Transactions: A Complete Guide
  3. Stripe Payments vs Balance Transactions: Why Counts Differ
  4. How to Build an Investor Pipeline for Startup Fundraising - OpenVC
  5. How to Write an Effective Investor Update | Tips & Templates
  6. Startup Investor Update: Monthly Founder Guide
  7. Slowly changing dimensions: implementation strategies
  8. How to model slowly changing dimensions in Denodo
  9. Stripe Webhook Idempotency, the Production Pattern
  10. Receive Stripe events in your webhook endpoint
  11. Types of events | Stripe API Reference
  12. How fee credits appear in balance transactions
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles