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

@@ -56,9 +56,12 @@ type fakeStore struct {
// cross-merchant tests while returning another company's shops.
// Camera ownership already has a home: cameraRefs, read through the
// cameraOwner method below.
siteOwner map[string]string // site id -> client id
clientRows map[string]ClientDetail
enrolment map[string]Enrolment
siteOwner map[string]string // site id -> client id
salesRows []Sale
saleOwner map[string]string // sale id -> client id
lastSaleQuery SaleQuery
clientRows map[string]ClientDetail
enrolment map[string]Enrolment
// Recorded calls, so a test can assert what the handler asked for rather
// than only what it returned.
@@ -314,6 +317,38 @@ func (f *fakeStore) SiteHealth(_ context.Context, clientID string) ([]SiteHealth
return out, nil
}
func (f *fakeStore) Sales(_ context.Context, q SaleQuery) ([]Sale, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastSaleQuery = q
var out []Sale
for _, sale := range f.salesRows {
if q.SiteID != "" && sale.SiteID != q.SiteID {
continue
}
if q.VisitorID != "" && sale.VisitorID != q.VisitorID {
continue
}
out = append(out, sale)
}
if q.Limit > 0 && len(out) > q.Limit {
out = out[:q.Limit]
}
return out, nil
}
func (f *fakeStore) Sale(_ context.Context, clientID, id string) (Sale, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, sale := range f.salesRows {
// Scoped, so the cross-tenant test is not vacuous.
if sale.ID == id && f.saleOwner[id] == clientID {
return sale, nil
}
}
return Sale{}, nil
}
func (f *fakeStore) ClientDetail(_ context.Context, clientID string) (ClientDetail, error) {
f.mu.Lock()
defer f.mu.Unlock()