Files
Behavision/server/internal/store/api_sales.go
Suriyakumarvijayanayagam 0e4cb1274e 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
2026-09-28 16:25:10 +05:30

117 lines
3.6 KiB
Go

// Reading individual sales.
//
// The conversion report has aggregated `purchases` since it existed and
// nothing could read a row of it, so "revenue was 41,000 last week" could not
// be checked against a till. These are the reads that make that number
// falsifiable from outside.
package store
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/loyaly/behavision-server/internal/api"
)
const saleCols = `
p.id::text, p.occurred_at, p.site_id::text, si.name, si.slug,
p.amount, p.currency, p.visitor_id::text, v.number, v.label,
p.visit_id::text, p.items, p.source, p.external_ref`
func scanSale(row pgx.Row) (api.Sale, error) {
var s api.Sale
var at time.Time
// LEFT JOINed: a sale with no visitor is an ordinary walk-in nobody
// identified, and it is still revenue.
var visitorID, visitorLabel, visitID *string
var number *int64
var items []byte
if err := row.Scan(&s.ID, &at, &s.SiteID, &s.Site, &s.SiteSlug,
&s.Amount, &s.Currency, &visitorID, &number, &visitorLabel,
&visitID, &items, &s.Source, &s.ExternalRef); err != nil {
return api.Sale{}, err
}
s.OccurredAt = at.UTC().Format(time.RFC3339)
if visitorID != nil {
s.VisitorID = *visitorID
}
if visitorLabel != nil {
s.VisitorLabel = *visitorLabel
}
if number != nil {
s.VisitorRef = api.VisitorRef(*number)
}
if visitID != nil {
s.VisitID = *visitID
}
// items is jsonb defaulting to '[]', but a null column would otherwise
// unmarshal into a nil slice and serialise as null - and a client mapping
// over it breaks on the first sale recorded without a basket.
s.Items = []string{}
if len(items) > 0 {
_ = json.Unmarshal(items, &s.Items)
if s.Items == nil {
s.Items = []string{}
}
}
return s, nil
}
// Sales lists purchases newest first, within one tenant.
//
// Bounded by `limit` and the date window rather than by a cursor. 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 simultaneous visits from the arrivals feed
// before `visits.seq` existed. Offering a cursor here would imply a delivery
// guarantee this table cannot make; narrowing the window is honest and is what
// a sales list is browsed by anyway.
func (s *Store) Sales(ctx context.Context, q api.SaleQuery) ([]api.Sale, error) {
rows, err := s.pool.Query(ctx, `
SELECT `+saleCols+`
FROM purchases p
JOIN sites si ON si.id = p.site_id
LEFT JOIN visitors v ON v.id = p.visitor_id
WHERE p.client_id = $1::uuid
AND p.occurred_at >= $2 AND p.occurred_at < $3
AND ($4 = '' OR p.site_id = $4::uuid)
AND ($5 = '' OR p.visitor_id = $5::uuid)
ORDER BY p.occurred_at DESC, p.id
LIMIT $6`,
q.ClientID, q.From, q.To, q.SiteID, q.VisitorID, q.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []api.Sale
for rows.Next() {
sale, err := scanSale(rows)
if err != nil {
return nil, err
}
out = append(out, sale)
}
return out, rows.Err()
}
// Sale is one purchase, scoped to the tenant. A row belonging to somebody else
// reads as absent, never as forbidden.
func (s *Store) Sale(ctx context.Context, clientID, id string) (api.Sale, error) {
row := s.pool.QueryRow(ctx, `
SELECT `+saleCols+`
FROM purchases p
JOIN sites si ON si.id = p.site_id
LEFT JOIN visitors v ON v.id = p.visitor_id
WHERE p.client_id = $1::uuid AND p.id::text = $2`, clientID, id)
sale, err := scanSale(row)
if errors.Is(err, pgx.ErrNoRows) {
return api.Sale{}, nil
}
return sale, err
}