DEV Community

Mihir kanzariya
Mihir kanzariya

Posted on

Give a conversion a status, not a boolean

The ad platform reports more conversions than the payment processor settled. Nobody wrote a bug. The two systems are counting different things, and they will keep disagreeing for as long as both exist.

The disagreement is by construction

An ad platform counts a conversion when someone it showed an ad to does the thing you told it to watch for, inside a window the platform defines, attributed by rules the platform owns. It is measuring the effect of its own inventory, using its own view of who saw what.

A payment processor counts money that cleared. It has no opinion about which click caused the charge, and it does not care that an ad was involved.

Pull from the ad platform's API and you inherit its window and its attribution model along with the number. That is not a caveat you can strip out. The count is defined by those rules, so importing the count imports the rules. The processor's number carries a different definition: settled funds, minus nothing you have not accounted for yet.

Both are honest answers. They answer different questions, and the moment you write one of them into a column labelled "revenue" you have picked a side without saying so.

Pick the authoritative event, then say which one it is

The engineering decision is small and specific: which event transitions a conversion into revenue?

If the answer is "a webhook from the processor," then the ad platform's API is a signal, not a source. If the answer is "the ad platform's postback," you have built a system that reports money nobody has received.

Most teams never make this decision explicitly. They write a conversion row when the checkout page fires, then join in ad spend later, and the mismatch surfaces months on when someone reconciles the dashboard against a bank statement and cannot explain the delta.

Make the choice in the schema, not in a comment. Every revenue figure your system emits should be traceable to one processor event with an ID you can go look up.

Reversals are the case people skip

Payments settle and then stop being settled. Refunds, chargebacks, a subscription renewal that fails on the second cycle. A dashboard that stamps a conversion as revenue at checkout is recording a fact that can still change, and it does not usually have anywhere to put the change.

The timing is the ugly part. Reversals arrive late. Disputes in particular can land well after the charge, so the most recent period is always the one with the fewest reversals recorded against it. Your last seven days look better than your last seven days will eventually have looked. That number is at its most flattering exactly when someone is reading it to decide whether to raise the budget.

You cannot fix this by waiting, because someone always wants today's number. You fix it by making the uncertainty part of the data.

Subscriptions break conversion value outright

For a one-off purchase, the value of a conversion is knowable shortly after it happens. For a subscription, it is not knowable at conversion time at all. The first payment is a first payment. It is not the customer's value, and it is not a share of the customer's value that you can compute yet.

Any ROAS you calculate on that first charge is a forecast wearing the clothes of a measurement. That is fine if you label it as a forecast. It is not fine when it sits in the same table, in the same font, as spend, which is an amount that really did leave a bank account.

Give a conversion a status, not a boolean

Here is the shape most systems start with:

{
  "click_id": "c_9f21",
  "campaign_id": "cmp_4",
  "converted": true,
  "revenue_cents": 4900
}
Enter fullscreen mode Exit fullscreen mode

converted: true has no room for "settled, then refunded" or "the processor has not confirmed this yet." Anything not final gets rounded to a win, because true is the only value that means anything happened.

{
  "click_id": "c_9f21",
  "campaign_id": "cmp_4",
  "source": "processor",
  "status": "pending",
  "processor_event_id": null,
  "amount_cents": 4900,
  "observed_at": "2026-08-03T11:02:14Z",
  "settled_at": null,
  "reversed_at": null,
  "reversal_reason": null
}
Enter fullscreen mode Exit fullscreen mode

status moves through a small state machine driven by processor webhooks, each transition carrying the event ID that caused it:

observed  ->  settled    (charge succeeded)
settled   ->  reversed   (refund, dispute, failed renewal)
observed  ->  expired    (window closed, no matching payment)
Enter fullscreen mode Exit fullscreen mode

Now a query can ask a real question. Settled and past the dispute window is money. Settled and recent is money that is probably money. Observed is a claim. Expired is the ad platform's count minus the processor's, which is the number people argue about, sitting in a column where you can look at it instead of guessing at it.

Keep the ad platform's figure too, as its own labelled series. Do not overwrite it and do not average it against the processor's. Two series that disagree tell you something. One blended series tells you nothing, and you cannot recover the inputs later.

Where this lands

We run into the same split on the affiliate side. Our attribution window is a 60-day cookie, and we are built on Stripe, so Stripe is where the money actually lands. When our own tool and Stripe disagree, people believe Stripe every time. The design conclusion we took from that is to stop treating our own count as a competing truth and treat it as the claim that Stripe either confirms or does not.

The reason to model status rather than a boolean is not accounting hygiene. It is that "not final yet" is a real state your system observes constantly, and if the schema has no place to put it, the code will put it somewhere wrong.

Disclosure: we build affiliate software for SaaS on top of Stripe, which is how we ended up thinking about this.

Top comments (0)