# Measure revenue

> Ask your agent to measure gross revenue, refunds, net revenue, transactions and payers in each currency, over time or by any event property, from the amount and currency your events carry, with the per-payer distribution and the top payers, and open the result as a proof page.

Canonical page: https://anectico.com/docs/investigate/revenue/


Revenue analytics answers how much money your product took, from how many paying people or
accounts, and who they are. It reads the **amount and the currency that ordinary product events
carry** and reports **gross revenue, refunds, net revenue, transactions and payers** — for the whole
window, over time, or split by any event property such as the plan — with the **distribution of net
revenue per payer** and a **ranking of your top payers**.

It is one kind of the [typed product query](/docs/investigate/product-analytics): `revenue`. Like
every other kind it is measured once, frozen under an execution key, and read again by result ID.
Your agent runs the measurement through MCP or the CLI. You open the stored result as a read-only
proof page.

Three rules shape everything below:

- **Money is exact.** An amount is a decimal string with four decimal places, such as `"19.9900"`.
  Nothing is rounded, and no figure passes through binary floating point.
- **Currencies are never added together and never converted.** The result has one section per
  currency. There is no combined total.
- **Missing money is never zero.** An event without a usable amount is counted and explained, and
  takes no part in any figure.

## Ask your agent

> "How much net revenue did we take in September, in each currency, split by plan? Include the
> three top payers and send me the proof link."

> "Compare September's USD revenue with August's. Tell me which events were not counted and why."

| Job | MCP tool or action | CLI command |
| --- | --- | --- |
| Check which revenue shapes and bounds the server accepts | `get_product_query_capabilities` | `anectico analytics capabilities` |
| Measure revenue | `query_product_analytics` | `anectico analytics query` |
| Read a stored revenue result again | `get_analytics_result` | `anectico analytics result get` |
| Page the payers behind a count, or the holder of a top-payer rank | `list_analytics_participants` | `anectico analytics result participants` |
| Prepare a later remeasurement of the same question, and compare the two | `get_analytics_remeasurement_source`, `compare_analytics_results` | `anectico analytics remeasurement source`, `anectico analytics remeasurement compare` |

These are MCP read actions. Your agent finds them with `list_read_actions` and runs them with
`execute_read_action`. Reading any revenue result needs `agents:content:read` in addition to
`analytics:read`, `analytics:query` and `persons:read`; the account unit also needs `groups:read`
(see [Permissions](#permissions)). Over MCP the key also needs `mcp:read`.

## What your agent gets back

The result comes back under **`revenue`**, with `state`, `closed`, `amount_scale`, `accounting`,
`currencies`, `other_currencies` and `truncated`. Each currency section carries `totals`,
`per_payer`, `buckets`, `breakdown` and `top_payers`. Every amount is an exact decimal string with
four decimal places, and every `payers` count and `top_payers` ranking declares a `selection_id`
that pages the people or accounts behind it. A trimmed example is in
[Run it and read the result](#run-it-and-read-the-result).

Over MCP, the result carries a link to the proof page in its `links`, in the entry titled
"Analytics result" (the address is its `href`). Every tool in the table that reads this stored
result carries the same link, and the comparison tool links both results side by side. The CLI
prints the result and no link.

## Open the proof

Open the link from your agent. It opens the stored result in the read-only result viewer, with the
project named in the page header. See [Proof pages](/docs/agents/proof-pages) and
[Result viewer](/docs/agents/result-viewer).

The page never measures again. It shows what was stored, when it was measured (in UTC) and when the
stored result expires. Its one action is **Copy link**. To change the question, ask your agent to
measure again with a new definition and a new execution key.

You see:

- **One section per currency**, each labelled with its code and saying that its figures are in
  that currency only. There is never a combined total; with more than one currency the headline
  says how many currencies were measured. Each section shows tiles for **Net revenue**, **Gross
  revenue**, **Refunds**, **Transactions**, **Refund transactions** and **Payers**. A negative net
  carries its minus sign. Payers are the resolved people, with unresolved identities shown beside
  them as **+N unresolved**.
- **Net per payer** and **Net per transaction**, labelled **Derived**: they are net divided by the
  payers (resolved or not) or by the transactions, on whole ten-thousandths, rounded half away
  from zero to the currency's usual decimals. A dash means there was nothing to divide by. The
  exact figure is in the tile's hover text.
- When resolved payers exist, a table of the **per-payer distribution**: the median, the 90th and
  99th percentiles and the highest net, as observed order statistics.
- For a series, a chart per currency with every period also listed in a table beside its exact
  amounts and its state in words. A period that is not finished or not fully covered is drawn
  hollow with a dashed edge; a period that could not be measured has no bar and is never drawn as
  zero.
- For a breakdown, a table per currency with each value, the explicit **No value recorded**,
  **Explicit null** and **Invalid value** rows when they exist, an **Other** row and a
  **Truncated** note when the limit left values out, and a **Total** row.
- For top payers, a ranked table per currency of net, gross, refunds and transactions. The page
  says plainly that opening a row discloses who paid most.
- **Data quality**, whenever any occurrence was not counted: a small table of how many fell into
  each reason, with a plain sentence for each, such as "The amount was text, not a number". If
  more currencies were measured than a result can show, a note says how many were left out and
  that they are counted, not added.
- For a compared measurement, **Compared with another window**: a table of **Net revenue**,
  **Gross revenue**, **Refunds**, **Transactions** and **Payers** with one column for this window,
  one for the comparison window and one for the **Difference**. A difference is this window minus
  the comparison window, exactly, and is shown only when both windows are fully measured;
  otherwise the cell is a dash and the table says why. No percentage is shown. Payers are two
  separate groups and are never subtracted. A comparison window that is still measuring or lacks
  coverage is labelled, and one whose coverage is unavailable shows **Not measured**, never zero.
- A result that is still measuring, has insufficient coverage or is unavailable says so above the
  figures, and says in words that the figures are not fully measured; an unavailable result shows
  no figures at all. A window with no revenue shows an empty state that explains how to send it.
  If the person who opens the link lacks the event-content permission, the page says so and names
  `agents:content:read`.

## Send revenue

Revenue is carried by an ordinary product event with two properties:

| Property | Holds | Rules |
| --- | --- | --- |
| `$revenue` | The amount, as a **JSON number**. | In **major units** of the currency (`19.99`, not `1999`). **Negative for a refund.** At most **four decimal places**. At most 99,999,999,999.9999 in size. |
| `$currency` | The currency, as a **string**. | Exactly **three upper-case letters**, such as `USD` or `EUR`. |

The currency is held to the shape of an ISO 4217 code and is not checked against the registry, so
any three upper-case letters are accepted and reported as they are. Anything else is **counted but
not measured** — see [Validity](#validity-what-counts-as-a-transaction). Two cases catch people out:

- A number sent as text (`"19.99"`) is **not** a number and is never converted.
- An amount with more than four decimal places is not rounded: it is refused as too precise. A sum
  computed in floating point such as `0.1 + 0.2` is `0.30000000000000004` and falls into this
  case. Compute in minor units as whole numbers and divide once (`1999 / 100` is `19.99`), or round
  to the currency's usual number of decimals before you send.

Choose the event or events that mean "money changed hands" — a purchase, a renewal, a refund — and
send the properties on each. The examples send a `plan` property too, so you can split revenue by
plan later.

### JavaScript

`captureRevenue` is `capture()` with the two properties set and the amount and currency checked
first. It throws, instead of sending a value revenue analytics would leave out, when the amount is
not a finite number, has more than four decimal places or is too large, or when the currency is not
three upper-case letters. Like `capture()`, it sends nothing until the visitor has agreed to product
analytics.

```typescript
import { AnalyticsClient } from '@anectico/sdk/analytics';

const events = new AnalyticsClient({
  endpoint: 'https://api.anectico.com',
  apiKey: process.env.ANECTICO_CAPTURE_API_KEY!,
});

events.captureRevenue('purchase', { amount: 19.99, currency: 'USD', properties: { plan: 'pro' } });

// A refund is a negative amount.
events.captureRevenue('refund', { amount: -5, currency: 'USD', properties: { plan: 'pro' } });
```

See [the JavaScript SDK reference](/docs/reference/javascript-sdk#send-revenue).

### Go

Every SDK other than JavaScript sends the two properties on an ordinary capture. The person comes
from the request context or `WithDistinctID`, as for any event:

```go
events.Capture(ctx, "purchase", map[string]any{
	"$revenue":  19.99,
	"$currency": "USD",
	"plan":      "pro",
}, anectico.WithDistinctID(userID))
```

### Python

```python
events.capture(
    'purchase',
    {'$revenue': 19.99, '$currency': 'USD', 'plan': 'pro'},
    distinct_id=user_id,
)
```

Send a `float` or `int`; a `Decimal` is not a JSON number, so convert it first.

### Android

Write the property names with an escaped dollar sign in Kotlin strings:

```kotlin
Anectico.capture("purchase", mapOf("\$revenue" to 19.99, "\$currency" to "USD", "plan" to "pro"))
```

### iOS

```swift
Anectico.capture("purchase", properties: ["$revenue": 19.99, "$currency": "USD", "plan": "pro"])
```

### Flutter

Use raw strings so Dart does not read `$` as interpolation:

```dart
await Anectico.capture('purchase', properties: {r'$revenue': 19.99, r'$currency': 'USD', 'plan': 'pro'});
```

### React Native

```typescript
await Anectico.capture('purchase', { $revenue: 19.99, $currency: 'USD', plan: 'pro' });
```

### Use property names you already send

If your events already carry an amount and a currency under other names, do not re-instrument:
name them in the definition with `amount_property` and `currency_property`
(for example `"amount_property": "order_total"`). They are ordinary event properties. The two
names must differ.

### Check that revenue arrives

Send one test purchase, then ask your agent to measure revenue for the last hour. The result's
`accounting.valid` must count your test event. If `accounting` counts it under a reason such as
`amount_not_number` or `currency_invalid`, fix the property that reason names (see
[Validity](#validity-what-counts-as-a-transaction)).

## Define a measurement

A revenue definition is the common definition (`version`, `window`, `timezone`, optional
`environment`, optional `unit`) plus one `revenue` object:

```json
{
  "version": 2,
  "timezone": "UTC",
  "window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-10-01T00:00:00Z"}},
  "revenue": {"events": [{"name": "purchase"}]}
}
```

| Field | Meaning |
| --- | --- |
| `revenue.events` | One to ten event selectors, each `{"name": "…"}` with optional `filters` of its own. An occurrence that matches two selectors counts once. |
| `revenue.origins` | Capture origins to read (`browser`, `mobile`, `platform`, `server`); empty means all. |
| `revenue.amount_property` | The property that holds the amount. Empty means `$revenue`. |
| `revenue.currency_property` | The property that holds the currency. Empty means `$currency`. |
| `revenue.currency` | Read the transactions of this one currency (three upper-case letters). Occurrences with no valid currency are still counted for [validity](#validity-what-counts-as-a-transaction). |
| `revenue.interval` | Empty, `day`, `week` (weeks begin on Monday) or `month`: a series per currency on your `timezone`'s calendar, clipped to the window. |
| `revenue.breakdown_property` | Split each currency by the value of one event property that the payment's own event carried. The text `"2"` and the number `2` are different values. |
| `revenue.breakdown_limit` | How many values to return per currency, 1 to 50; `0` or empty means 10. Only with `breakdown_property`. |
| `revenue.top_payers` | How many of each currency's highest payers to rank, 0 to 25; empty or `0` means none. |

`unit` is `{"kind": "person"}` (the default) or `{"kind": "account", "account_type": "company"}` to
count **paying accounts** by the account an event names. A payment whose event carries no key of
that account type is counted but measured in no account (see
[Payers](#payers-people-accounts-and-unresolved-identities)). The account unit refuses person
filters and audiences with `QUERY_UNIT_FILTER_UNSUPPORTED`.

An **interval and a breakdown cannot be combined**: choose a time series or a split, not both. The
definition's own `breakdown` is not accepted either; use `revenue.breakdown_property` for the
split. A `comparison` window is accepted on one currency's totals only: see
[Compare two windows](#compare-two-windows).

The usual restrictions of the common definition apply: `environment`, person-property filters and
audiences (`cohorts`) restrict which people are measured, and event filters narrow the events read.

### Examples

Totals for a month, in every currency, for one event:

```json
{
  "version": 2,
  "timezone": "UTC",
  "window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-10-01T00:00:00Z"}},
  "revenue": {"events": [{"name": "purchase"}]}
}
```

Monthly revenue for one currency, across a year, on the Europe/Dublin calendar, with the
properties named explicitly:

```json
{
  "version": 2,
  "timezone": "Europe/Dublin",
  "window": {"absolute": {"start": "2026-01-01T00:00:00Z", "end": "2027-01-01T00:00:00Z"}},
  "revenue": {
    "events": [{"name": "purchase"}, {"name": "renewal"}],
    "amount_property": "$revenue",
    "currency_property": "$currency",
    "currency": "EUR",
    "interval": "month"
  }
}
```

Revenue by plan, the five biggest plans per currency, with the three top payers:

```json
{
  "version": 2,
  "timezone": "UTC",
  "window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-10-01T00:00:00Z"}},
  "revenue": {
    "events": [{"name": "purchase"}],
    "breakdown_property": "plan",
    "breakdown_limit": 5,
    "top_payers": 3
  }
}
```

Revenue per paying company:

```json
{
  "version": 2,
  "timezone": "UTC",
  "unit": {"kind": "account", "account_type": "company"},
  "window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-10-01T00:00:00Z"}},
  "revenue": {"events": [{"name": "purchase"}], "top_payers": 10}
}
```

September's USD revenue beside August's:

```json
{
  "version": 2,
  "timezone": "UTC",
  "window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-10-01T00:00:00Z"}},
  "comparison": {"absolute": {"start": "2026-08-01T00:00:00Z", "end": "2026-09-01T00:00:00Z"}},
  "revenue": {"events": [{"name": "purchase"}], "currency": "USD"}
}
```

### Compare two windows

Add a `comparison` window to measure the same definition over a second window in the same
measurement. Both windows are read at the same moment, under the same identity resolution, so the
two sets of figures describe one observation.

A comparison compares **one currency's totals**, because amounts in different currencies are never
compared. It is accepted only when:

- `revenue.currency` names the one currency;
- `revenue.interval`, `revenue.breakdown_property` and `revenue.top_payers` are not set; and
- the two windows do not overlap. Windows that only touch, such as two consecutive months, do not
  overlap.

Anything else is refused with `QUERY_REVENUE_INVALID` before the query runs.

The result then carries a `comparison` object beside the usual fields, which keep describing
`window`:

```json
{
  "state": "PRODUCT_REVENUE_STATE_MEASURED",
  "currencies": [{"currency": "USD", "totals": {"gross": "1300.0000", "refunds": "50.0000", "net": "1250.0000", "transactions": "6", "refund_transactions": "1", "payers": {"people": "4", "unresolved": "0", "accounts": "0", "selection_id": "…"}}}],
  "comparison": {
    "state": "PRODUCT_REVENUE_STATE_MEASURED",
    "closed": true,
    "accounting": {"occurrences": "9", "valid": "9", "amount_missing": "0", "amount_null": "0", "amount_not_number": "0", "amount_out_of_range": "0", "amount_too_precise": "0", "currency_missing": "0", "currency_invalid": "0"},
    "account_attribution": null,
    "currency": {"currency": "USD", "totals": {"gross": "2000.0000", "refunds": "0.0000", "net": "2000.0000", "transactions": "9", "refund_transactions": "0", "payers": {"people": "7", "unresolved": "0", "accounts": "0", "selection_id": "…"}}, "per_payer": {"payers": "7", "p50": "250.0000", "p90": "500.0000", "p99": "500.0000", "max": "500.0000"}}
  }
}
```

| Field | Meaning |
| --- | --- |
| `comparison.state`, `comparison.closed` | The comparison window's own [state](#result-states). One window can be measured while the other is still open or lacks coverage. |
| `comparison.accounting` | The comparison window's own [validity counts](#validity-what-counts-as-a-transaction). |
| `comparison.account_attribution` | For the account unit, the comparison window's attributed and missing transactions. |
| `comparison.currency` | The currency's totals and per-payer distribution over the comparison window. It is `null` when that window holds no valid transaction of the currency: in a measured window that is a real zero, not a missing answer. |

`manifest.comparison` describes the comparison window itself: its range, its coverage and its
population (everyone with a valid transaction in that window). The payers of the comparison window
are a selection of their own, so a person who paid in both windows is listed in each. Page them
with `comparison.currency.totals.payers.selection_id`, exactly as for the current window.

No difference or percentage is returned: subtract the two exact amounts yourself. The proof page
shows the two windows side by side with the exact difference of each amount, and states a
difference only when both windows are fully measured.

To be **alerted** when a measure changes from one period to the next, use a
[metric watch](/docs/investigate/metric-watches#when-revenue-changes) on a saved revenue insight;
the watch adds the previous period itself, so the insight needs no `comparison` of its own.

## Money

- **Exact decimal strings.** Every amount in a result is a **string with exactly four decimal
  places**: `"19.9900"`, `"-3.5000"`, `"0.0000"`. It is never a JSON number, so no reader rounds it
  through floating point. Read it with a decimal type, or as a whole number of ten-thousandths.
  A result states this in `amount_scale` (`4`).
- **Every sum is exact.** Gross, refunds and net are sums of whole ten-thousandths. Ten payments of
  `0.10` make exactly `1.0000`.
- **Never converted, never added across currencies.** Every figure lives inside one currency's
  section, ranked by transactions and then by code. There is no exchange rate and no total of two
  currencies. A result holds at most **10 currencies**; any more are reported as **counts only** in
  `other_currencies` (how many currencies, transactions and refund transactions were left out),
  never as an amount.
- **A refund is negative at the source and a magnitude in the result.** `refunds` is the sum of the
  negative amounts as a non-negative number, and `net = gross - refunds`, which can be negative.

## What every measure means

The same measures are reported for each currency's **totals**, for **each period** of a series, for
**each value** of a breakdown and for the breakdown's **other** remainder.

| Measure | Definition |
| --- | --- |
| `gross` | The sum of the positive amounts. |
| `refunds` | The sum of the negative amounts, as a non-negative magnitude. |
| `net` | `gross` minus `refunds`, exactly. It may be negative. |
| `transactions` | The valid occurrences of the set, refunds and zero amounts included. |
| `refund_transactions` | The valid occurrences with a negative amount. |
| `payers` | The distinct subjects with at least one **positive** amount in the set: `people`, `unresolved`, `accounts` and a `selection_id`. A subject with only refunds or zero amounts is not a payer. See [Payers](#payers-people-accounts-and-unresolved-identities). |

Every uint64 count is a decimal string in JSON.

The server computes **no averages**. To read revenue per transaction, divide `net` by
`transactions`; for revenue per payer, divide `net` by `people` plus `unresolved` (or by `accounts`).
Divide exact decimals, not floating-point numbers. The proof page does this on whole
ten-thousandths, rounds half away from zero, and labels the result **derived**.

### Over time

With an `interval`, each currency carries `buckets`: the calendar periods clipped to the window,
ascending and contiguous. A transaction counts in the period that holds its time, so the periods'
gross, refunds, net and both transaction counts **add up to the totals**. A payer of two periods
counts in each, so payer counts do **not** add up. Each period has its own state and `closed` flag.
A window may hold at most 100 periods.

### By property

With a `breakdown_property`, each currency carries a `breakdown`:

- `rows` are the values with the greatest gross, up to `breakdown_limit`, ranked by gross, then
  transactions, then value.
- `missing`, `null` and `invalid` are their own explicit rows, present when they hold a transaction:
  the payment's event carried no value, an explicit null, or a list, object or non-finite number. A
  payment is never dropped or folded into another value.
- `other` is the union of the values the limit left out, with `values` saying how many; its payers
  are distinct over that union, so they are not the sum of the omitted rows. `truncated` is `true`
  exactly when `other` is present.

The rows, the special rows and `other` add up to the currency's totals. A payer of two values counts
in each, so payer counts do not add up.

## Validity: what counts as a transaction

An occurrence is a **valid transaction** only when its amount is a number with at most four decimal
places within the size limit **and** its currency is three upper-case letters. Every other matching
occurrence is **counted under one reason** — the first that applies, in the order below — in the
result's `accounting`, and takes no part in any figure. `occurrences` equals `valid` plus every
reason.

| Reason | The occurrence's |
| --- | --- |
| `amount_missing` | amount property is absent. |
| `amount_null` | amount is an explicit `null`. |
| `amount_not_number` | amount is text, true/false, a list or an object. `"19.99"` is counted here: text is never converted. |
| `amount_out_of_range` | amount is larger than 99,999,999,999.9999 in size. |
| `amount_too_precise` | amount has more than four decimal places. It is never rounded. |
| `currency_missing` | amount is usable but the currency property is absent. |
| `currency_invalid` | amount is usable but the currency is not exactly three upper-case letters, such as `usd`, `USDT` or a number. |

A missing amount is never zero. When you set `revenue.currency`, an occurrence whose valid currency
is a different one is not selected and appears nowhere; an occurrence with no valid currency is
still accounted for, because it cannot be excluded by a currency it does not have. An event
delivered twice is one occurrence.

For the account unit, `account_attribution` splits the valid transactions into `attributed_events`
(the event named an account of the chosen type) and `missing_events` (it did not). Only the
attributed ones are measured.

## Payers: people, accounts and unresolved identities

For the person unit, `payers.people` counts **resolved people** and `payers.unresolved` counts
**identities that never resolved to a person**. The two are separate counts and are **never added
together**. An identity that pays anonymously and is later merged into a person is one payer. For
the account unit, `payers.accounts` counts accounts.

`payers.selection_id` pages the **resolved people** (or the accounts) behind a count; unresolved
identities are counted beside them and are not listed. See
[Page the people behind a count](#page-the-people-behind-a-count).

## The per-payer distribution and the top payers

**`per_payer`** summarizes the **net revenue of each payer** in a currency: `payers` (how many),
`p50`, `p90`, `p99` and `max`. Money is skewed, so these are **observed order statistics, never an
average**: the payers' nets are sorted and the one at position `floor(level × payers)` is returned,
so every figure is an amount one payer actually has, never an interpolation. It covers **resolved
people only** (or accounts); unresolved identities are not in it. It is absent when the currency has
no resolved payer.

**`top_payers`** (when `revenue.top_payers` is above zero) ranks each currency's highest-paying
resolved people or accounts by net, highest first, ties broken by the subject's key. An unresolved
identity is never ranked. Each entry carries `rank`, `net`, `gross`, `refunds` and `transactions` —
**money and no identity**. The subject of rank N is **participant N** of `top_payers.selection_id`,
read through the participants route.

> **Top payers disclose who paid most.** The ranking names nobody, but anyone who can read the
> result can page the participants of its selection and so learn who your highest payers are.
> Choose `top_payers` only when that is acceptable for the people who can read the result.

## Result states

Missing data is never a measured zero. The whole result, and each period of a series, has a state
and a `closed` flag (the end of the span against the frozen observation boundary):

| State | Meaning |
| --- | --- |
| `PRODUCT_REVENUE_STATE_MEASURED` | The span has ended and platform coverage is sufficient. |
| `PRODUCT_REVENUE_STATE_IMMATURE` | The span has not ended. The figures observed so far are **disclosed** and may still grow. |
| `PRODUCT_REVENUE_STATE_INSUFFICIENT_EVIDENCE` | The span has ended but platform coverage is not sufficient, for example because recent events were still arriving. The observed figures are **disclosed** and may undercount. |
| `PRODUCT_REVENUE_STATE_UNAVAILABLE` | Platform coverage is unavailable. **Nothing is disclosed**: no accounting, no currencies and no selections. |

`MEASURED` is the only state that means complete. In the middle two the figures are real but not
final; the result's `manifest.current.coverage` says why. A series reports a state for **each
period**: a period that ended before the observation boundary can be measured while a later one is
still immature.

## Permissions

Amounts, currencies and breakdown values are customer-supplied event content. Reading **any**
revenue result therefore needs the event-content permission `agents:content:read` in addition to
`analytics:read`, `analytics:query` and `persons:read` — at execution, and on every later read of
the result, of its people and of its exports. The account unit also needs `groups:read`. Without
them the request is refused with `403` and a message naming what is missing. See
[permissions](/docs/reference/permissions).

Because who paid is part of the result, **anyone who can read a revenue result can page its payers
and its top payers**.

## What is refused

A refused definition returns `400 InvalidArgument` with a message that begins with a stable code.

| Code | Refused |
| --- | --- |
| `QUERY_REVENUE_INVALID` | The definition's own `breakdown`; a `comparison` window without `revenue.currency`, with an `interval`, a `breakdown_property` or `top_payers`, or overlapping the window; an unknown `interval`; a blank amount or currency property, or one property named for both; a `currency` that is not three upper-case letters; a `breakdown_limit` without a `breakdown_property`; a breakdown by the amount or the currency property; an `interval` together with a `breakdown_property`. |
| `QUERY_REVENUE_LIMIT` | A `breakdown_limit` above 50, a `top_payers` above 25, or an `interval` that would make more than 100 periods in the window. |
| `QUERY_SELECTOR_LIMIT` | No event selector, or more than ten. |
| `QUERY_UNIT_FILTER_UNSUPPORTED` | A person filter or an audience with the account unit. |

A result that would list more than 20,000,000 people across all its payer selections fails with
`PRODUCT_RESULT_FAILURE_REASON_EXECUTION_BUDGET` instead of being cut short.

## Where a revenue result is accepted

| Feature | Revenue |
| --- | --- |
| [Result export](/docs/manage/save-and-export-data) | Accepted: the payers of a currency's totals, a period, a value or the top payers export like any other population. The export carries **people or accounts and no amounts**. |
| [Saved insights](/docs/investigate/saved-insights) and [dashboard widgets](/docs/investigate/service-health-and-dashboards) | The recipe is saved and shown as its own result, relative windows included. |
| [Scheduled reports](/docs/investigate/scheduled-reports) | Accepted: the transaction count and each currency's net as figures, and one row per currency (or, for one currency with a series, per period) of exact amounts and counts. Breakdown values stay in the insight. An insight with a `comparison` window shows the comparison window's transaction count and net beside the current ones when that window is fully measured. |
| [Remeasurement](/docs/investigate/product-analytics#remeasure-an-explicit-original-result) | Accepted through MCP, the CLI and REST. |
| [Metric watches](/docs/investigate/metric-watches#watch-revenue) | Accepted for one measure (`net`, `gross` or `refunds`) of one currency, against a threshold or against the period before: the insight must set `currency` and no `interval`, `breakdown_property` or `top_payers`. Any other revenue insight is refused with `UNSUPPORTED_RESULT_SELECTOR`. |
| [Live screens](/docs/investigate/live-screens) | Refused with `UNSUPPORTED_RESULT_SELECTOR`. |

There is **no per-transaction drill-down**: `analytics result contribution` refuses a revenue
reference, so only the roster of payers is available.

## Limits

| Limit | Value |
| --- | --- |
| Event selectors (`revenue.events`) | 1 to 10 |
| Currencies in one result | 10; the rest are counted in `other_currencies` |
| Breakdown values per currency (`breakdown_limit`) | 1 to 50; 10 when omitted |
| Top payers per currency (`top_payers`) | 0 to 25 |
| Periods in one series | 100 |
| Decimal places of an amount | 4 |
| Size of one amount | 99,999,999,999.9999 |
| People listed across all of a result's payer selections | 20,000,000 |

The measurement limits shared by every kind (measurements in flight, time to run, result lifetime)
are in [Limits](/docs/reference/limits). `anectico analytics capabilities` reports the `revenue`
kind with both units, its `intervals`, its default `revenue_amount_property` and
`revenue_currency_property`, and the `max_revenue_currencies`, `max_revenue_breakdown_rows`,
`default_revenue_breakdown_rows`, `max_revenue_buckets`, `max_revenue_top_payers`,
`revenue_amount_scale`, `max_revenue_amount` and `max_revenue_participant_rows` bounds.

## Run it and read the result

```bash
anectico --project PROJECT_UUID analytics query --file revenue.json --execution-key EXECUTION_UUID
```

MCP's `query_product_analytics` takes the same definition, and REST is `POST /api/v1/analytics/query`
with `project_id`, `execution_key` and `definition`. The response carries the result under
**`revenue`**, and only when `status` is `PRODUCT_RESULT_STATUS_READY`. Read a result again with
`anectico analytics result get RESULT_UUID`, MCP's `get_analytics_result` or
`GET /api/v1/analytics/results/{result_id}`.

A trimmed result for revenue by plan with top payers. Here the payers were identified as people;
one currency is shown:

```json
{
  "status": "PRODUCT_RESULT_STATUS_READY",
  "revenue": {
    "state": "PRODUCT_REVENUE_STATE_MEASURED",
    "closed": true,
    "amount_scale": 4,
    "accounting": {
      "occurrences": "16", "valid": "13", "amount_missing": "1", "amount_null": "0",
      "amount_not_number": "1", "amount_out_of_range": "0", "amount_too_precise": "0",
      "currency_missing": "0", "currency_invalid": "1"
    },
    "currencies": [
      {
        "currency": "USD",
        "totals": {
          "gross": "20.9900", "refunds": "5.0000", "net": "15.9900",
          "transactions": "12", "refund_transactions": "1",
          "payers": {"people": "2", "unresolved": "0", "accounts": "0", "selection_id": "SELECTION_UUID"}
        },
        "per_payer": {"payers": "2", "p50": "14.9900", "p90": "14.9900", "p99": "14.9900", "max": "14.9900"},
        "buckets": [],
        "breakdown": {
          "rows": [
            {"value": {"string_value": "scale"}, "measures": {"gross": "19.9900", "refunds": "5.0000", "net": "14.9900", "transactions": "2", "refund_transactions": "1", "payers": {"people": "1", "unresolved": "0", "accounts": "0", "selection_id": "SELECTION_UUID"}}},
            {"value": {"string_value": "pro"}, "measures": {"gross": "1.0000", "refunds": "0.0000", "net": "1.0000", "transactions": "10", "refund_transactions": "0", "payers": {"people": "1", "unresolved": "0", "accounts": "0", "selection_id": "SELECTION_UUID"}}}
          ],
          "missing": null, "null": null, "invalid": null, "other": null, "truncated": false
        },
        "top_payers": {
          "selection_id": "TOP_PAYERS_SELECTION_UUID",
          "entries": [
            {"rank": 1, "net": "14.9900", "gross": "19.9900", "refunds": "5.0000", "transactions": "2"},
            {"rank": 2, "net": "1.0000", "gross": "1.0000", "refunds": "0.0000", "transactions": "10"}
          ]
        }
      }
    ],
    "other_currencies": null,
    "truncated": false
  }
}
```

In this example three of the sixteen occurrences were not counted: one had no amount, one sent its
amount as text, and one used a lower-case currency. Net revenue per transaction is
`15.9900 / 12`, and net per payer `15.9900 / 2`; the figures above are the exact inputs.

### Page the people behind a count

Every `payers` and every `top_payers` declares a `selection_id`. Page the resolved people (or
accounts) behind it with the same call as any other kind:

```bash
anectico --project PROJECT_UUID analytics result participants RESULT_UUID \
  --selection-id SELECTION_UUID --limit 50
```

MCP's `list_analytics_participants` and `GET /api/v1/analytics/results/{result_id}/participants`
take the same `selection_id`, `limit` and `cursor`; continue with `next_cursor` and keep the result,
selection and limit unchanged. A selection lists **resolved people only**: unresolved identities
are counted in `unresolved` and are not listed. Each selection can also be exported.

To learn who holds **rank N** in `top_payers`, read the participants of
`top_payers.selection_id`: entry N is participant `ordinal` N. A ranking holds at most 25 entries,
so the first page of 25 or more covers it.

## What revenue does not do

- **No currency conversion.** Amounts are never converted and never added across currencies. There
  is no exchange rate and no combined total.
- **No subscription metrics.** There is no monthly recurring revenue, annual recurring revenue,
  churn, expansion, contraction or cohort revenue retention. A payment is a transaction, not a
  subscription.
- **No lifetime value model.** There is no predicted or modelled customer value. Net per payer is
  what the window's payers paid, divided exactly.
- **Alerting covers one currency's total.** A [metric watch](/docs/investigate/metric-watches#watch-revenue)
  alerts on the net, gross or refunds of an insight that names one currency, compared each period
  with a threshold — including a period with no transactions, which is an amount of 0 (see
  [When revenue stops](/docs/investigate/metric-watches#when-revenue-stops)) — or with the period
  before it (see [When revenue changes](/docs/investigate/metric-watches#when-revenue-changes)). It
  does not alert on a time series, a breakdown value or a top payer, and it never combines
  currencies.
- **A comparison is two windows of one currency's totals.** A time series, a breakdown and a top
  payers ranking are measured for one window only, and no percentage change is computed for you.
- **No live screens.** A [live screen](/docs/investigate/live-screens) cannot show a revenue
  result: it is refused with `UNSUPPORTED_RESULT_SELECTOR`.
- **Only what your events report.** Revenue describes what the selected events carried. An event
  with no usable amount or currency is counted and explained, not guessed. Comparing the revenue of
  two groups is an observation, not evidence that one caused the other.
