ReviewOS

stacks/analyticshq

[feature] Dynamic revenue / currency for conversions

#22
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Gap

"Revenue" is a static integer value per goal (routes/analytics.ts:290; goals.value/conversions.value integers). Fathom supports per-event value + currency.

Proposed

Accept a dynamic amount + currency on conversion events; aggregate revenue and per-goal value from actual event values.

Priority: P2.


From the 2026-07-20 privacy-first competitive audit (vs. Plausible / Fathom / Simple Analytics / Umami).

9 days ago

Shipped in 2853eef.

What landed

analyticshq('Purchase', { value: 19.99, currency: 'USD' }). The amount is the event's, falling back to the goal's default; the currency is the event's, then the goal's, then the site's. A fixed-price product need not repeat its price on every purchase, a single-currency site need not repeat its currency at all, and a store selling in several can say so per event.

New: GET /api/sites/{siteId}/revenue (per currency and per goal), default_amount + currency on goals, currency on the site.

Minor units, always

Stored as a whole number of the currency's smallest unit. Money in a float drifts when summed, and the drift surfaces as a total that disagrees with the customer's own books by a few cents and cannot be explained.

The exponent is not always 2 — this is the part that is quietly wrong in most implementations. JPY, KRW and ISK have no minor unit: ¥1000 is 1000 units, not 100000. Getting that wrong overstates a Japanese site's revenue by 100×, consistently, so it reads as a great quarter rather than a bug. BHD, KWD, JOD and OMR go the other way with three decimals. Both sets are in the table and both are tested.

Strings are shifted by moving the decimal point in the string rather than multiplying, because multiplying money by a power of ten is how rounding errors get in. Extra precision is truncated, never rounded: 1.009 USD is 100 cents, and rounding up would invent money.

bigint, not integer — 2.1 billion minor units is only 21 million dollars, which a real store passes. Verified a 9,000,000,000 sum round-trips.

Per currency, never summed across

Adding dollars to euros needs an exchange rate, and picking one to produce a single headline number would fabricate the figure — silently, at whatever rate applied that day, in a number the customer would reconcile against their own books. So the report groups by currency; a single-currency site sees one row.

Rows with an amount but no currency are excluded rather than bucketed under a blank heading, where they'd be read as the site's default. And when no currency resolves at all, no revenue is stored — defaulting to USD would label a European store's sales as dollars.

The legacy value columns are left alone. goals.value and conversions.value hold whatever their creator typed, with no record of the unit, so repurposing them would silently redefine every row written before this. The ambiguity stays confined to old rows.

Verification. 36 checks against real Postgres including mixed USD/EUR/JPY reported separately. Five sabotages fail by name: assuming every currency has two decimals, re-converting an already-minor goal default, defaulting a missing currency to USD, rounding instead of truncating, and dropping GROUP BY currency. 277 tests pass.

Worth being straight about one gap: no dev server was running and booting the full stack risked colliding with other work on this machine, so the precedence chain was extracted into resolveConversionAmount() and tested directly rather than over HTTP. Better code either way — the rule deserved to be testable — but the beacon path was exercised through that function and the database, not through a live request.

Deliberately not included

  • No UI.
  • Revenue is not filterable by segment. conversions carries a smaller column set than page_views, so wiring the #23 filter grammar across it needs its own column map rather than a cast.
  • These are not accounting figures. /collect is public and the site id ships in the tracking snippet, so an amount is exactly as trustworthy as the site's own front end. That's inherent to client-side revenue tracking and true of every product in this category — noted in the code so nobody later mistakes it for something reconcilable.

Sign in to comment on this issue.