Sales you can read, not only sum, and a home screen in one call

Three routes over data the server already stores.

GET /api/sales and /api/sales/{id}. The purchases table has existed
since the conversion report did, and nothing could read a row of it - so
"revenue was 41,000 last week" was a number that could not be checked
against a till. The list carries the customer reference the product
actually shows people (V-42) beside the uuid, and a sale with NO
customer is listed rather than joined away: an unidentified walk-in is
still revenue, and an inner join would make this disagree with the
conversion report computed over the same rows.

No cursor, deliberately. A keyset cursor needs a monotonic
server-assigned column and purchases has none; ordering by
(occurred_at, id) with a random uuid tie-break is exactly the shape that
silently dropped four of six simultaneous visits from the arrivals feed
before visits.seq existed. Offering one here would imply a delivery
guarantee this table cannot make, so the list is bounded by the date
window and a limit - which is how a sales list is browsed anyway.

GET /api/dashboard/summary. Four calls a client had to make and then
combine, which is how the desktop Footfall screen once produced its
headline by adding the daily bars up: silently too high, because a
customer who came twice is one person and two bucket-visitors. The
combining happens here, against Footfall and SiteHealth rather than new
SQL - a second definition of "unique visitor" or of "online" drifts, and
a home screen that disagrees with the report it links to is the one
nobody trusts afterwards. fraction_below_gate travels with the count for
the same reason it does everywhere else: it is what says whether the
headcount is a number or a floor.

Today is cut in the shop's timezone. In the one market this ships to,
UTC is five and a half hours wrong.

An unknown shop filter is a 400, not an ignored parameter. This API has
already been bitten once by a silently ignored filter handing back the
whole estate, which is a wrong number nobody would question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-28 16:25:10 +05:30
parent abcf6aa012
commit 0e4cb1274e
6 changed files with 557 additions and 3 deletions

View File

@@ -136,6 +136,9 @@ type Store interface {
// --- platform administration ---
CreateClientWithOwner(ctx context.Context, in NewClientInput) (NewClientResult, error)
Sales(ctx context.Context, q SaleQuery) ([]Sale, error)
Sale(ctx context.Context, clientID, id string) (Sale, error)
ListClients(ctx context.Context) ([]ClientRow, error)
// The admin drill-down. Each takes the merchant's client id explicitly,
@@ -350,6 +353,16 @@ func (s *Server) Routes() *http.ServeMux {
mux.HandleFunc("PUT /api/visitors/{id}/profile", s.authed(s.handleSaveProfile))
mux.HandleFunc("POST /api/purchases", s.authed(s.handlePurchase))
// Reading sales, not just aggregating them. /api/reports/conversion has
// summed this table since it existed; nothing could read a row of it, so
// "revenue was 41,000" could not be checked against a till.
mux.HandleFunc("GET /api/sales", s.authed(s.handleSales))
mux.HandleFunc("GET /api/sales/{id}", s.authed(s.handleSale))
// The merchant home screen in one call, composed from the functions the
// reports already use rather than from new arithmetic.
mux.HandleFunc("GET /api/dashboard/summary", s.authed(s.handleDashboard))
// Platform administration. Not public registration: an open endpoint that
// mints tenants is a far larger thing to secure than one behind an account
// that already exists. The `provision` CLI remains the bootstrap path,