diff --git a/server/internal/api/types.go b/server/internal/api/types.go index acc5a4e..21ba151 100644 --- a/server/internal/api/types.go +++ b/server/internal/api/types.go @@ -198,6 +198,21 @@ type VisitRow struct { Similarity float64 `json:"similarity,omitempty"` Quality float64 `json:"quality,omitempty"` Attributes map[string]any `json:"attributes,omitempty"` + + // What they bought on that visit, if anything. Attached here because + // "when was this customer last in" and "did they buy" are one question + // staff ask in one breath, and answering it used to mean two calls and a + // join in the client. + // + // Spend and Currency are omitted when a single visit somehow holds more + // than one currency: adding rupees to dollars produces something that + // looks like money and is not, and Purchases still says a sale happened. + // A purchase with no visit_id is not here at all - it belongs to the + // customer rather than to a moment - and is listed by + // GET /api/sales?customer=V-42. + Purchases int `json:"purchases,omitempty"` + Spend float64 `json:"spend,omitempty"` + Currency string `json:"currency,omitempty"` } type Profile struct { diff --git a/server/internal/store/api_admin_monitor_live_test.go b/server/internal/store/api_admin_monitor_live_test.go index 47ca749..6888c35 100644 --- a/server/internal/store/api_admin_monitor_live_test.go +++ b/server/internal/store/api_admin_monitor_live_test.go @@ -3,6 +3,7 @@ package store import ( "context" "testing" + "time" ) // A malformed identifier must MISS, never error. @@ -55,3 +56,113 @@ func TestLiveMalformedIdentifiersMissRatherThanError(t *testing.T) { t.Errorf("Sale(not-a-uuid) matched %q", s.ID) } } + +// A customer's history carries what they bought on each visit. +// +// Live, because the whole risk is in the SQL: a plain join onto purchases +// would return a visit TWICE when it holds two sales, making a customer look +// like they came more often than they did. The LATERAL aggregate is what +// prevents that, and an in-memory fake asserting on it would only be checking +// the fake. +func TestLiveTwoSalesOnOneVisitDoNotDuplicateTheVisit(t *testing.T) { + st := liveStore(t) + ctx := context.Background() + + clientID, siteID, visitorID, visitID := seedVisitWithSales(t, st, 2) + + rows, err := st.VisitorHistory(ctx, clientID, visitorID, 50) + if err != nil { + t.Fatal(err) + } + seen := 0 + for _, r := range rows { + if r.ID != visitID { + continue + } + seen++ + if r.Purchases != 2 { + t.Errorf("purchases = %d, want 2", r.Purchases) + } + if r.Spend != 300 || r.Currency != "INR" { + t.Errorf("spend = %v %s, want 300 INR", r.Spend, r.Currency) + } + } + if seen != 1 { + t.Fatalf("the visit appears %d times, want exactly 1 - two sales on one "+ + "visit must not make a customer look like two visits", seen) + } + _ = siteID +} + +// Mixed currencies on one visit report the count and NO figure. Adding rupees +// to dollars produces something that looks like money and is not. +func TestLiveMixedCurrenciesOnOneVisitReportNoTotal(t *testing.T) { + st := liveStore(t) + ctx := context.Background() + + clientID, _, visitorID, visitID := seedVisitWithSales(t, st, 0) + seedSale(t, st, clientID, visitID, visitorID, 100, "INR") + seedSale(t, st, clientID, visitID, visitorID, 50, "USD") + + rows, err := st.VisitorHistory(ctx, clientID, visitorID, 50) + if err != nil { + t.Fatal(err) + } + for _, r := range rows { + if r.ID != visitID { + continue + } + if r.Purchases != 2 { + t.Errorf("purchases = %d, want 2 - the sales still happened", r.Purchases) + } + if r.Spend != 0 || r.Currency != "" { + t.Errorf("spend = %v %q, want no figure for mixed currencies", + r.Spend, r.Currency) + } + } +} + +// seedVisitWithSales makes one tenant, one customer, one visit, and `sales` +// purchases of 150 INR each against that visit. +func seedVisitWithSales(t *testing.T, st *Store, sales int) (clientID, siteID, visitorID, visitID string) { + t.Helper() + clientID, siteID = seedTenant(t, st, "hist"+stamp(), 0, false) + ctx := context.Background() + at := time.Date(2026, 9, 3, 11, 0, 0, 0, time.UTC) + + if err := st.pool.QueryRow(ctx, ` + INSERT INTO visitors (client_id, number, label, first_seen_at) + VALUES ($1::uuid, 1, 'Visitor 1', $2) RETURNING id::text`, + clientID, at).Scan(&visitorID); err != nil { + t.Fatalf("seed visitor: %v", err) + } + if err := st.pool.QueryRow(ctx, ` + INSERT INTO visits (client_id, site_id, visitor_id, source_event_id, + occurred_at, camera_id, is_new_visitor) + VALUES ($1::uuid, $2::uuid, $3::uuid, 'hist-e1', $4, 'door', true) + RETURNING id::text`, + clientID, siteID, visitorID, at).Scan(&visitID); err != nil { + t.Fatalf("seed visit: %v", err) + } + for i := 0; i < sales; i++ { + seedSale(t, st, clientID, visitID, visitorID, 150, "INR") + } + return clientID, siteID, visitorID, visitID +} + +func seedSale(t *testing.T, st *Store, clientID, visitID, visitorID string, + amount float64, currency string) { + t.Helper() + var siteID string + if err := st.pool.QueryRow(context.Background(), + `SELECT site_id::text FROM visits WHERE id = $1::uuid`, visitID).Scan(&siteID); err != nil { + t.Fatalf("site of visit: %v", err) + } + if _, err := st.pool.Exec(context.Background(), ` + INSERT INTO purchases (client_id, site_id, visitor_id, visit_id, + amount, currency, occurred_at) + VALUES ($1::uuid, $2::uuid, $3::uuid, $4::uuid, $5, $6, now())`, + clientID, siteID, visitorID, visitID, amount, currency); err != nil { + t.Fatalf("seed sale: %v", err) + } +} diff --git a/server/internal/store/api_people.go b/server/internal/store/api_people.go index 20f5dba..1e211e7 100644 --- a/server/internal/store/api_people.go +++ b/server/internal/store/api_people.go @@ -94,9 +94,19 @@ func (s *Store) VisitorHistory(ctx context.Context, clientID, visitorID string, rows, err := s.pool.Query(ctx, ` SELECT vi.id::text, vi.occurred_at, si.name, vi.camera_id, - vi.is_new_visitor, vi.similarity, vi.quality, vi.attributes + vi.is_new_visitor, vi.similarity, vi.quality, vi.attributes, + pu.n, pu.total, pu.cur, pu.currencies FROM visits vi JOIN sites si ON si.id = vi.site_id + -- LATERAL rather than a join onto purchases directly: two sales on one + -- visit would otherwise return that visit twice and the customer would + -- appear to have been in more often than they were. + LEFT JOIN LATERAL ( + SELECT count(*) AS n, sum(p.amount) AS total, + max(p.currency) AS cur, count(DISTINCT p.currency) AS currencies + FROM purchases p + WHERE p.visit_id = vi.id AND p.client_id = vi.client_id + ) pu ON true WHERE vi.client_id = $1 AND vi.visitor_id = $2::uuid ORDER BY vi.occurred_at DESC LIMIT $3`, clientID, visitorID, limit) @@ -109,11 +119,20 @@ func (s *Store) VisitorHistory(ctx context.Context, clientID, visitorID string, for rows.Next() { var v api.VisitRow var at time.Time - var sim, qual *float64 + var sim, qual, total *float64 + var nPurchases, nCurrencies int + var cur *string if err := rows.Scan(&v.ID, &at, &v.Site, &v.CameraID, &v.IsNew, - &sim, &qual, &v.Attributes); err != nil { + &sim, &qual, &v.Attributes, + &nPurchases, &total, &cur, &nCurrencies); err != nil { return nil, err } + v.Purchases = nPurchases + // One currency or none. Mixed is left as a count with no figure + // rather than a sum that means nothing. + if nCurrencies == 1 && total != nil && cur != nil { + v.Spend, v.Currency = *total, *cur + } v.OccurredAt = at.UTC().Format(time.RFC3339) if sim != nil { v.Similarity = *sim