Repository navigation
feat(analytics): commercial reporting over the whole order history - #44
Merged
Merged
Conversation
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
This was referenced Aug 26, 2026
Merged
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Four ways this could have been quietly wrong
Cross-currency totals.
orders.currencypermits several. Every money figure is a listkeyed by currency, so summing them is impossible rather than merely discouraged. Collapses
to one entry for a single-currency shop.
Join fan-out. Joining
orderstoorder_itemsand summingtotal_amountmultiplieseach 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 newSHOP_TIMEZONEsetting (default
Europe/Berlin). On UTC, a 23:30 order in Berlin lands on the previousday — an error that looks like a data problem for weeks.
Checkout from
orders.status. Status records only where an order is now: a cancelledorder is indistinguishable from one that never reached checkout. The funnel reads
paymentsinstead, which is written when checkout starts and survives whatever happens next.
Limits, stated rather than implied
that needs session data (S2).
refunded Stripe charge.
against every SKU on it.
Verification
They fail without the change. With the module removed — the state of
main:The integration tests would fail identically:
/v1/admin/analytics/*did not exist, and/openapi.jsonwent 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:
Full suite:
Note for existing deployments
Three indexes are added.
Base.metadata.create_allcreates missing tables but does notalter existing ones, so a database that predates this needs them applied by hand —
docs/analytics.mdcarries theCREATE INDEX IF NOT EXISTSstatements. Without them thefigures are still correct, just sequentially scanned.
The admin frontend consuming these lands separately.