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
117 lines
3.6 KiB
Go
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
|
|
}
|