// 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 }