From dae18d651b56a933e781a22e41f2397317dfef92 Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Tue, 29 Sep 2026 14:49:23 +0530 Subject: [PATCH] A customer's history now says whether they bought anything "When was this customer last in" and "did they buy" are one question staff ask in one breath, and answering it meant two calls and a join in the client. Each visit row carries purchases, spend and currency. LATERAL, not a join onto purchases. A plain join returns the visit TWICE when it holds two sales, which would make a customer look like they came more often than they did - a wrong number of exactly the kind this product is otherwise careful about, arrived at by adding a feature. Mixed currencies on one visit report the count and NO figure. Adding rupees to dollars produces something that looks like money and is not, and the sales still happened, so the count is the honest part to keep. A purchase with no visit_id is deliberately absent: it belongs to the customer rather than to a moment, and GET /api/sales?customer=V-42 lists it. The two surfaces together cover every sale exactly once. Both properties are asserted in the LIVE store tests, because both live in the SQL. An in-memory fake asserting that a LATERAL does not duplicate a row would only be checking the fake. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj --- server/internal/api/types.go | 15 +++ .../store/api_admin_monitor_live_test.go | 111 ++++++++++++++++++ server/internal/store/api_people.go | 25 +++- 3 files changed, 148 insertions(+), 3 deletions(-) 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