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)"