From 1312ecf4e4370c8bd580f7ca9b267e67a9e3a451 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 26 Aug 2026 15:36:40 +0200 Subject: [PATCH] docs: document the analytics endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API gained four admin analytics endpoints (OpenTaberna/fastapi#44). Refreshing openapi.snapshot.json made the drift check fail with four undocumented paths, which is the check doing its job. Documents them in the endpoint reference along with the metric definitions — what counts as revenue and what does not — because those are choices rather than facts and leaving them implicit in a query is how two readers come away with different numbers. Carries across the limits rather than letting a reader infer something stronger: the funnel is an order funnel and cannot see browsing shoppers, partial refunds are not modelled, per-SKU return rate is an upper bound because returns are per order, and product revenue sums line values which need not match order totals. Also records why checkout is counted from payments rather than order status, that money is grouped by currency and must not be summed, and adds SHOP_TIMEZONE to the configuration reference. Closes #7 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YY1ekLLeFLkAU2kvdQ8Ey4 --- API/Architecture.md | 66 +++++++++++++++++++++++++++++++++++++++++++ Configuration.md | 11 ++++++++ home.md | 1 + openapi.snapshot.json | 20 +++++++++++++ 4 files changed, 98 insertions(+) diff --git a/API/Architecture.md b/API/Architecture.md index e7bcf0a..e2351b1 100644 --- a/API/Architecture.md +++ b/API/Architecture.md @@ -32,6 +32,7 @@ persistence: | Orders | `/v1/orders` | Draft orders, checkout, cancellation, return requests | | Inventory | `/v1/admin/inventory` | Stock levels and reservations | | Admin | `/v1/admin/orders` | Back-office fulfillment | +| Analytics | `/v1/admin/analytics` | Commercial reporting over the order history | | Returns | `/v1/admin/returns` | Admin decisions on return requests | | Webhooks | `/v1/webhooks` | Inbound payment provider callbacks | | Health | `/health` | Liveness and readiness | @@ -112,6 +113,71 @@ See [Orders and Fulfillment](/Orders-and-Fulfillment). | `POST` | `/v1/admin/orders/{order_id}/ship` | Mark order as shipped | admin | | `PATCH` | `/v1/admin/returns/{return_id}` | Approve, reject or complete a return | admin | +### Analytics — `/v1/admin/analytics` + +Commercial reporting, computed in SQL over **all** orders rather than a recent sample. + +| Method | Path | Purpose | Auth | +|---|---|---|---| +| `GET` | `/v1/admin/analytics/summary` | Revenue, refunds, AOV, units, vs the previous period | admin | +| `GET` | `/v1/admin/analytics/timeseries` | The same figures bucketed by day, week or month | admin | +| `GET` | `/v1/admin/analytics/products` | Per-SKU units, revenue, return rate, unsold stock | admin | +| `GET` | `/v1/admin/analytics/funnel` | Where orders stop | admin | + +All four take optional `from` and `to` calendar dates, inclusive, defaulting to the last 30 +days. A window longer than five years is refused: the figures are computed live rather than +from a rollup table, so an unbounded range is a slow query waiting to happen. + +#### What counts as revenue + +These are choices, not facts, so they are written down rather than left implicit in a query: + +| Term | Definition | +|---|---| +| Gross revenue | Orders in `paid`, `ready_to_ship` or `shipped` | +| Refunded | Orders in `refunded` — **whole-order only** | +| Net revenue | Gross less refunded | +| Average order value | Gross divided by revenue-producing orders | +| Units | Line quantities on revenue-producing orders | + +Never counted: `draft`, `pending_payment`, `cancelled`, and anything soft-deleted. + +#### Money is grouped by currency + +Every money-bearing response is a **list keyed by currency**, not a single figure. +`orders.currency` permits more than one, and a total summed across currencies is not +slightly wrong — it is meaningless. For the usual single-currency shop the list has one +entry and reads like a plain number. + +A client must not add these together. The response shape exists to make that mistake +visible rather than silent. + +#### Days are cut in the shop's timezone + +Buckets use `SHOP_TIMEZONE` (default `Europe/Berlin`), not UTC. An order placed at 23:30 +UTC belongs to the next day in Berlin, and bucketing on UTC would file a day's takings +against the wrong day — an error that looks like a data problem for weeks. See +[Configuration](/Configuration). + +#### What these numbers cannot tell you + +Stated plainly, because a reader will otherwise infer something stronger: + +- **The funnel is an order funnel, not a visitor funnel.** It begins at order creation and + cannot see shoppers who browsed without ordering. Visitor conversion needs session data + the API does not collect. +- **Partial refunds are not modelled.** `orders.status = refunded` is all-or-nothing, so + refund figures will not reconcile against a partially refunded Stripe charge. +- **Per-SKU return rate is an upper bound.** Returns are recorded per order, not per line, + so a return on a two-line order counts against both SKUs. +- **Product revenue need not equal order revenue.** Product figures sum line values; an + order total may carry shipping or adjustments belonging to no line. + +Checkout is counted from the `payments` table rather than from `orders.status`. Status +records only where an order is *now*, so a cancelled order is indistinguishable from one +that never reached checkout, and an order that shipped then was refunded no longer says it +shipped. A payment row is written when checkout starts and survives whatever follows. + ### Inventory — `/v1/admin/inventory` Stock is deliberately separate from the catalogue. The `inventory` block on an item is diff --git a/Configuration.md b/Configuration.md index 4dc2ebc..e6f7746 100644 --- a/Configuration.md +++ b/Configuration.md @@ -157,6 +157,17 @@ outside Compose or against a Dashboard-managed endpoint. Too short and a slow payment loses its reservation mid-flow; too long and abandoned carts hold stock nobody can buy. +## Analytics + +| Setting | Default | Description | +|---|---|---| +| `SHOP_TIMEZONE` | `Europe/Berlin` | IANA timezone the shop trades in | + +Analytics buckets days in this zone rather than UTC, so "today" matches the operator's day. +Leave it wrong and evening orders land on the following day for any shop east of Greenwich — +the figures stay internally consistent, which is what makes it hard to notice. An unknown +zone is rejected at request time with a `422` naming the value, rather than a `500`. + ## Object storage — MinIO / S3 | Setting | Default | Description | diff --git a/home.md b/home.md index dc13a0e..99eaa6b 100644 --- a/home.md +++ b/home.md @@ -128,6 +128,7 @@ system. It is one possible shop UI, not *the* shop UI — that is the point of t | Fulfillment | Carrier labels, packing slips, the job queue and outbox | | Shipments | Tracking numbers and label files | | Returns | Customer return requests and admin decisions | +| Analytics | Revenue, product performance and the order funnel | | Health | Liveness and readiness probes | The full endpoint list is on the [API Architecture](/API/Architecture) page, and a live, diff --git a/openapi.snapshot.json b/openapi.snapshot.json index 722595a..ab862e4 100644 --- a/openapi.snapshot.json +++ b/openapi.snapshot.json @@ -14,6 +14,26 @@ "summary": "Readiness check" } }, + "/v1/admin/analytics/funnel": { + "get": { + "summary": "Order funnel (admin)" + } + }, + "/v1/admin/analytics/products": { + "get": { + "summary": "Product performance (admin)" + } + }, + "/v1/admin/analytics/summary": { + "get": { + "summary": "Commercial summary (admin)" + } + }, + "/v1/admin/analytics/timeseries": { + "get": { + "summary": "Revenue and volume over time (admin)" + } + }, "/v1/admin/inventory/": { "get": { "summary": "List inventory items (admin)"