Skip to content

feat(analytics): commercial reporting over the whole order history - #44

Merged
PhilippTheServer merged 1 commit into
OpenTaberna:mainfrom
PhilippTheServer:pl/analytics
Aug 26, 2026
Merged

PhilippTheServer merged 1 commit into
OpenTaberna:mainfrom
PhilippTheServer:pl/analytics

Conversation

@PhilippTheServer

Copy link
Copy Markdown
Contributor

Closes #43

S1 of the telemetry programme. The admin dashboard computed every money figure client-side
from the last 100 orders — its own comment said so. This computes them in SQL over all
orders.

GET /v1/admin/analytics/summary     revenue, refunds, AOV, units, vs previous period
GET /v1/admin/analytics/timeseries  the same, bucketed by day / week / month
GET /v1/admin/analytics/products    per-SKU units, revenue, return rate, dead stock
GET /v1/admin/analytics/funnel      where orders stop

Four ways this could have been quietly wrong

Cross-currency totals. orders.currency permits several. Every money figure is a list
keyed by currency, so summing them is impossible rather than merely discouraged. Collapses
to one entry for a single-currency shop.

Join fan-out. Joining orders to order_items and summing total_amount multiplies
each order's value by its line count. Order money and line money are separate statements,
merged in Python; the join is only ever used for quantities.

UTC buckets. Days are cut with timezone(tz, created_at) using a new SHOP_TIMEZONE
setting (default Europe/Berlin). On UTC, a 23:30 order in Berlin lands on the previous
day — an error that looks like a data problem for weeks.

Checkout from orders.status. Status records only where an order is now: a cancelled
order is indistinguishable from one that never reached checkout. The funnel reads payments
instead, which is written when checkout starts and survives whatever happens next.

Limits, stated rather than implied

  • The funnel is an order funnel. It cannot see shoppers who browsed without ordering —
    that needs session data (S2).
  • Partial refunds are not modelled, so refunds will not reconcile against a partially
    refunded Stripe charge.
  • Per-SKU return rate is an upper bound — returns are per order, so one return counts
    against every SKU on it.
  • Product revenue need not equal order revenue — lines carry no shipping or adjustments.

Verification

$ uv run pytest tests/test_analytics_unit.py tests/test_analytics_integration.py -q
34 passed in 1.44s

They fail without the change. With the module removed — the state of main:

$ python3 -m pytest tests/test_analytics_unit.py -q
    from app.services.analytics.functions import (
E   ModuleNotFoundError: No module named 'app.services.analytics'
ERROR tests/test_analytics_unit.py
1 error in 0.20s

The integration tests would fail identically: /v1/admin/analytics/* did not exist, and
/openapi.json went from 26 paths to 30.

The integration fixture seeds a deterministic dataset inside March 2025, a window nothing
else touches, so figures are exact regardless of what else is in the dev database. It covers
two currencies, a soft-deleted order carrying 9 units and 4000 in value that must vanish from
every figure, a two-line order that must not count twice, an order at 23:30 UTC that must
bucket to the next Berlin day, and four payment states driving the funnel.

Cross-checked against raw SQL on the dev database:

$ psql -c "SELECT currency, sum(...) FROM orders WHERE deleted_at IS NULL GROUP BY currency"
 EUR | 42493 | 0 | 14

$ curl .../analytics/summary?from=2026-01-01&to=2026-12-31
  EUR  gross=42493  refunded=0  orders=14

Full suite:

$ uv run pytest -q
967 passed, 5 skipped, 12 warnings in 18.08s
$ ruff check src/app tests/ && ruff format --check src/app/services/analytics tests/
All checks passed!
14 files already formatted

Note for existing deployments

Three indexes are added. Base.metadata.create_all creates missing tables but does not
alter existing ones, so a database that predates this needs them applied by hand —
docs/analytics.md carries the CREATE INDEX IF NOT EXISTS statements. Without them the
figures are still correct, just sequentially scanned.

The admin frontend consuming these lands separately.

The admin dashboard derived every money figure client-side from the most recent
100 orders. Its own comment admitted it. That is fine for a demo and wrong for
a shop: the numbers stop being true at order 101, and there was no trend, no
product performance and no view of where orders stop converting.

Adds an admin-only analytics service computing the same figures in SQL over all
orders:

    GET /v1/admin/analytics/summary     — revenue, refunds, AOV, units, vs previous period
    GET /v1/admin/analytics/timeseries  — the same, bucketed by day/week/month
    GET /v1/admin/analytics/products    — per-SKU units, revenue, return rate, dead stock
    GET /v1/admin/analytics/funnel      — where orders stop

Four decisions worth naming, each guarding a way to be quietly wrong:

Money is grouped by currency. orders.currency permits several and a
cross-currency total is meaningless, so the schema makes summing them
impossible rather than merely discouraged.

Order money and line money are queried separately. Joining orders to
order_items and summing total_amount multiplies each order's value by its line
count; the join is used only for quantities.

Days are cut in SHOP_TIMEZONE (new setting, default Europe/Berlin) via
Postgres AT TIME ZONE. UTC buckets file evening orders under the wrong day for
any shop east of Greenwich.

Checkout is counted from payments, not orders.status. Status records only where
an order is now, so it cannot tell a cancelled checkout from one that never
happened.

Limits are documented rather than implied: the funnel is an order funnel and
cannot see browsing shoppers (that is S2), partial refunds are not modelled,
and per-SKU return rate is an upper bound because returns are per order.

Three indexes support live aggregation. create_all does not alter existing
tables, so docs/analytics.md carries the SQL for databases that predate this.

Closes #43

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YY1ekLLeFLkAU2kvdQ8Ey4
@PhilippTheServer
PhilippTheServer merged commit d80763f into OpenTaberna:main Aug 26, 2026
5 checks passed
PhilippTheServer added a commit to OpenTaberna/wiki that referenced this pull request Aug 26, 2026
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


Claude-Session: https://claude.ai/code/session_01YY1ekLLeFLkAU2kvdQ8Ey4

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No commercial analytics: the admin dashboard guesses from the last 100 orders

1 participant