Skip to main content
/admin/reports answers the questions the dashboard cannot: how a quarter went, which plan is actually selling, how much of the money is renewals rather than new business, and whether the trials turn into anything. The dashboard’s thirty-day window stays exactly where it is. A window that moves cannot be compared between two visits, which is the property an operations overview needs and the reason it was fixed in the first place. This is the screen where the window moves.

Three rules that decide the numbers

The period keys on when the order was paid, and that instant never moves. orders.paid_at is written the first time an order reaches a settled state and never again. Before this existed, reporting keyed on updated_at — so recording a refund in March silently moved a February sale into March, and the same report run twice over the same closed period returned different numbers. An integration test now records a refund against a closed period and asserts the figures do not change. Provider money and wallet credit are never added. They are separate columns in every table on the screen. A wallet balance was already counted as revenue when it was funded, so adding the two counts it twice — which is exactly the mistake a single “revenue” figure invites. Refunds are reported on the day they were issued, not against the sale they reverse. “What did we refund in March” is a question an operator can act on; “what did we sell in February that was later refunded” retroactively changes a month that had closed.

What the screen shows

Trial conversion is a cohort measure and the screen says so beside the figure. The denominator is trials claimed inside the period; the numerator counts conversions at any later time. A period ending today therefore reads low — a trial claimed yesterday has had one day to convert — and that is a property of the question rather than a fault in the measurement.

The period

Four presets cover what people actually ask for — 7, 30, 90, and 365 days — and the two date fields take anything else. A period longer than two years is refused with the limit named, rather than becoming a query that runs until a proxy times out. Days are bucketed in your own timezone, taken from the browser and passed to the API. “Sales on the third” means the operator’s third; a report bucketed in UTC puts an evening sale several hours east on the wrong day, and an integration test asserts the same sale lands on the 2nd in UTC and the 3rd in Moscow. The plan breakdown reads the code, version, and billing period off the plan version each order actually referenced rather than off the plan’s current state. That is what plan versions are for: a price change last week must not re-price a sale from last month.

Payment health

The second tab answers a different question with the same period: not what was sold, but whether the money arrived. With four bundled adapters, an acquirer that starts refusing cards is otherwise visible as support tickets and a growing stuck-payment queue rather than as a number that moved. Two rates, because two things fail. A customer abandoning a checkout and a gateway refusing a card are not the same event, and only the second is the acquirer’s. Collapsing them into one “success rate” produces a figure that drops every time a campaign reaches people who were never going to buy — which is how a real settlement problem gets lost in noise. Intents still in flight are in neither denominator and are counted apart. One created five minutes ago has not failed, and including it would make the current hour look catastrophic exactly when somebody is watching. The oldest open intent is reported as an age, which is the stuck-payment queue expressed as a number. A rate is absent rather than zero when nothing reached a decision. “Nobody paid” and “everybody failed” are opposite facts, and a screen showing 0% for a quiet adapter would send somebody to investigate one nobody used. The sample size travels beside every rate for the same reason: 50% of four is a coincidence and 50% of four hundred is an incident. Time to settle is measured from the settlement event, not from the intent’s updated_at. A later reconciliation touches the row, and keying on it would inflate the latency of every payment anybody looked at twice.

Webhook intake

Settlement and notification fail independently: a gateway that takes the money and cannot deliver the callback looks healthy from the customer’s side and produces a stuck queue here. The table reports what arrived, what was processed, what failed, and what was rejected for a bad signature — a non-zero rejection count is either a rotated secret or somebody probing the endpoint, and both are worth seeing.

The export

GET /v1/panel/reports/sales/export takes the same four parameters and returns one CSV with a section column — operation, plan, day, refund, trial — rather than four files, because the breakdowns are four views of one period. Amounts are exported in minor units, unscaled, exactly as they are stored. Dividing by a hundred is wrong for every zero-decimal currency, and it is something the reader can do knowing their own.

Permissions

Both routes need finance.read, the same permission the order and payment screens need. A sales report is money, and an installation that has decided who may read money has already made this decision; a permission of its own would let the two drift into disagreeing about the same figures.

What is not here

Advertising attribution and per-node traffic are separate absences tracked in the roadmap. These screens report what was sold and whether the money arrived, not where the customer came from or which node carried them.

Admin operations

The dashboard, its fixed window, and the incidents and drift beside it.

Orders and payments

The states an order moves through and what settles it.

Financial invariants

Why provider money, wallet credit, and refunds are three figures rather than one.

Wallet and ledger

Where a balance comes from, and why spending it is not new revenue.