From 5721f0d80024edc94b4c28baee3fd164ea1ffcb9 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 08:27:29 -0700 Subject: [PATCH 01/17] marketcheck: failing tests for settings, the run and the suggestion table Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/marketcheck/compute_test.go | 59 ++++ core/internal/marketcheck/helpers_test.go | 193 ++++++++++++ core/internal/marketcheck/run_test.go | 338 +++++++++++++++++++++ core/internal/marketcheck/settings_test.go | 141 +++++++++ 4 files changed, 731 insertions(+) create mode 100644 core/internal/marketcheck/compute_test.go create mode 100644 core/internal/marketcheck/helpers_test.go create mode 100644 core/internal/marketcheck/run_test.go create mode 100644 core/internal/marketcheck/settings_test.go diff --git a/core/internal/marketcheck/compute_test.go b/core/internal/marketcheck/compute_test.go new file mode 100644 index 000000000..e3327ba54 --- /dev/null +++ b/core/internal/marketcheck/compute_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package marketcheck_test + +import ( + "testing" + + "github.com/gablelbm/gable/internal/marketcheck" +) + +// The suggestion table: band 5 percent, floor 15 percent unless a case says +// otherwise. Cost 500 cents gives a floor of ceil(500/0.85) = 589. +func TestSuggest(t *testing.T) { + bandBP, floorBP := int64(500), int64(1500) + cases := []struct { + name string + ours, theirs, cost int64 + wantPos string + wantPrice int64 + wantReason string + wantDiffPct float64 + wantSuggestionAvailable bool + }{ + {"above lowers to the band", 1000, 800, 500, "above", 840, "lower_to_band", 25.0, true}, + {"above target over the floor", 1000, 600, 500, "above", 630, "lower_to_band", 66.7, true}, + {"above target under the floor", 1000, 550, 500, "above", 589, "held_at_floor", 81.8, true}, + {"below raises to the band", 700, 1000, 500, "below", 950, "raise_to_band", -30.0, true}, + {"below raise still max with floor", 520, 560, 500, "below", 589, "raise_to_band", -7.1, true}, + {"in line keeps", 1000, 1000, 500, "in_line", 1000, "keep", 0.0, true}, + {"in line edge of band is in line", 1050, 1000, 500, "in_line", 1050, "keep", 5.0, true}, + {"in line under the floor takes the floor", 550, 550, 500, "in_line", 589, "held_at_floor", 0.0, true}, + {"no cost, no suggestion", 1000, 800, 0, "above", 0, "no_cost", 25.0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := marketcheck.Suggest(c.ours, c.theirs, c.cost, bandBP, floorBP) + if got.Position != c.wantPos { + t.Fatalf("position %q want %q", got.Position, c.wantPos) + } + if got.DiffPct != c.wantDiffPct { + t.Fatalf("diff %v want %v", got.DiffPct, c.wantDiffPct) + } + if got.Reason != c.wantReason { + t.Fatalf("reason %q want %q", got.Reason, c.wantReason) + } + if got.SuggestedCents != c.wantPrice { + t.Fatalf("suggested %d want %d", got.SuggestedCents, c.wantPrice) + } + }) + } +} + +func TestSuggest_FloorRoundsUp(t *testing.T) { + // cost 333 at a 10 percent floor: 333/0.9 = 370 exactly; 334 gives 371.11, up to 372. + if got := marketcheck.Suggest(100, 100, 334, 500, 1000); got.SuggestedCents != 372 || got.Reason != "held_at_floor" { + t.Fatalf("got %+v", got) + } +} diff --git a/core/internal/marketcheck/helpers_test.go b/core/internal/marketcheck/helpers_test.go new file mode 100644 index 000000000..90cd1b5c2 --- /dev/null +++ b/core/internal/marketcheck/helpers_test.go @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package marketcheck_test + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gablelbm/gable/internal/marketcheck" + "github.com/google/uuid" +) + +const testHook = "test-hook-0001" + +// memStore is an in memory system_settings. +type memStore struct { + mu sync.Mutex + rows map[string]string +} + +func newMemStore() *memStore { return &memStore{rows: map[string]string{}} } + +func (m *memStore) Read(_ context.Context, k string) (string, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + v, ok := m.rows[k] + return v, ok, nil +} +func (m *memStore) Write(_ context.Context, k, v string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.rows[k] = v + return nil +} +func (m *memStore) Delete(_ context.Context, k string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.rows, k) + return nil +} + +// fakeVault seals with base64 under the shared envelope. +type fakeVault struct{ present bool } + +func (v fakeVault) Present() bool { return v.present } +func (v fakeVault) Seal(p string) (string, error) { + return "enc:v1:" + base64.StdEncoding.EncodeToString([]byte(p)), nil +} +func (v fakeVault) Open(s string) (string, error) { + b, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(s, "enc:v1:")) + return string(b), err +} + +type memCatalog struct { + yard marketcheck.Yard + products map[uuid.UUID]marketcheck.Product +} + +func (c *memCatalog) Yard(context.Context) (marketcheck.Yard, error) { return c.yard, nil } +func (c *memCatalog) Products(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]marketcheck.Product, error) { + out := map[uuid.UUID]marketcheck.Product{} + for _, id := range ids { + if p, ok := c.products[id]; ok { + out[id] = p + } + } + return out, nil +} + +type passTx struct{} + +func (passTx) RunInTx(ctx context.Context, fn func(context.Context) error) error { return fn(ctx) } + +func product(sku string, price, cost int64) marketcheck.Product { + return marketcheck.Product{ID: uuid.New(), SKU: sku, Description: "Desc " + sku, Category: "Lumber", + UOM: "PCS", PriceCents: price, CostCents: cost} +} + +func catalogOf(ps ...marketcheck.Product) *memCatalog { + c := &memCatalog{yard: marketcheck.Yard{Name: "Kelowna Main Yard", Town: "Kelowna, BC"}, + products: map[uuid.UUID]marketcheck.Product{}} + for _, p := range ps { + c.products[p.ID] = p + } + return c +} + +func ids(ps ...marketcheck.Product) []uuid.UUID { + out := make([]uuid.UUID, len(ps)) + for i, p := range ps { + out[i] = p.ID + } + return out +} + +// logCapture collects every log line. +type logCapture struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (l *logCapture) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.buf.Write(p) +} +func (l *logCapture) String() string { + l.mu.Lock() + defer l.mu.Unlock() + return l.buf.String() +} + +func newLogger() (*slog.Logger, *logCapture) { + c := &logCapture{} + return slog.New(slog.NewTextHandler(c, &slog.HandlerOptions{Level: slog.LevelDebug})), c +} + +// configured returns a service pointed at the stub, with settings saved. +func configured(t *testing.T, stub *httptest.Server, cat *memCatalog, mod func(*marketcheck.SettingsInput)) (*marketcheck.Service, *memStore, *logCapture) { + t.Helper() + st := newMemStore() + lg, cap := newLogger() + svc := marketcheck.NewService(st, cat).WithVault(fakeVault{present: true}).WithTxRunner(passTx{}). + WithLogger(lg).WithBatchTimeout(2 * time.Second) + hook := testHook + in := marketcheck.SettingsInput{ConnectURL: ptr(stub.URL), WebhookID: &hook, + StoreLabel: ptr("Home Depot, Spokane WA: nearest store with live prices")} + if mod != nil { + mod(&in) + } + if _, err := svc.SaveSettings(context.Background(), in); err != nil { + t.Fatalf("save settings: %v", err) + } + return svc, st, cap +} + +func ptr[T any](v T) *T { return &v } + +// flowStub answers each request with fn; it records bodies and paths. +type flowStub struct { + *httptest.Server + mu sync.Mutex + bodies []string + paths []string +} + +func newFlow(t *testing.T, fn func(w http.ResponseWriter, r *http.Request, body string)) *flowStub { + t.Helper() + f := &flowStub{} + f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + f.mu.Lock() + f.bodies = append(f.bodies, string(b)) + f.paths = append(f.paths, r.Method+" "+r.URL.Path+" "+r.Header.Get("Content-Type")) + f.mu.Unlock() + fn(w, r, string(b)) + })) + t.Cleanup(f.Close) + return f +} + +// matchAll answers every sent item as matched at the given price in dollars. +func matchAll(price float64) func(http.ResponseWriter, *http.Request, string) { + return func(w http.ResponseWriter, r *http.Request, body string) { + var skus []string + for _, part := range strings.Split(body, `"sku":"`)[1:] { + skus = append(skus, part[:strings.Index(part, `"`)]) + } + var rows []string + for _, s := range skus { + rows = append(rows, fmt.Sprintf(`{"sku":%q,"status":"matched","match":{"title":"HD %s","theirPrice":%v,"listedPrice":%v,"link":"https://www.homedepot.com/p/%s","confidence":90,"normNote":""},"note":""}`, s, s, price, price, s)) + } + _, _ = fmt.Fprintf(w, `{"store":{"id":"1234","name":"Spokane Store"},"rows":[%s]}`, strings.Join(rows, ",")) + } +} + +func isErr(err error, sub string) bool { + return err != nil && strings.Contains(err.Error(), sub) +} + +var _ = errors.New diff --git a/core/internal/marketcheck/run_test.go b/core/internal/marketcheck/run_test.go new file mode 100644 index 000000000..7dc510b56 --- /dev/null +++ b/core/internal/marketcheck/run_test.go @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package marketcheck_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/gablelbm/gable/internal/marketcheck" + "github.com/google/uuid" +) + +func rowOf(t *testing.T, res *marketcheck.Result, sku string) marketcheck.Row { + t.Helper() + for _, r := range res.Rows { + if r.SKU == sku { + return r + } + } + t.Fatalf("no row for %s in %+v", sku, res.Rows) + return marketcheck.Row{} +} + +func TestRun_NotConfigured(t *testing.T) { + svc, _ := newSvc(true) + _, err := svc.Run(context.Background(), []uuid.UUID{uuid.New()}) + if err != marketcheck.ErrNotConfigured { + t.Fatalf("want ErrNotConfigured, got %v", err) + } +} + +func TestRun_RequestShapeHoldsNoCostAndTheRowsAreOursRecomputed(t *testing.T) { + a := product("DL_SPF_2X4_L08_S", 542, 300) + b := product("DL_SPF_2X6_L08_S", 900, 700) + // the flow lies about the difference: our figures are recomputed + stub := newFlow(t, func(w http.ResponseWriter, r *http.Request, body string) { + _, _ = fmt.Fprint(w, `{"store":{"id":"1234","name":" Spokane Store "},"rows":[ + {"sku":"DL_SPF_2X4_L08_S","status":"matched","diffPercent":-99,"position":"below","match":{"title":"HD stud","theirPrice":4.00,"listedPrice":40.00,"link":"https://www.homedepot.com/p/1","confidence":150,"normNote":"Sold as a pack of 10; price divided by 10"},"note":""}, + {"sku":"DL_SPF_2X6_L08_S","status":"unmatched","note":"No equivalent found"}, + {"sku":"NOT_SENT","status":"matched","match":{"title":"x","theirPrice":1,"listedPrice":1,"link":"","confidence":1,"normNote":""}}]}`) + }) + cat := catalogOf(a, b) + svc, _, _ := configured(t, stub, cat, func(in *marketcheck.SettingsInput) { + in.StoreID, in.StoreZip, in.FlowName = ptr("1234"), ptr("99201"), ptr("Pricing flow") + }) + res, err := svc.Run(context.Background(), ids(a, b)) + if err != nil { + t.Fatal(err) + } + // the request: exactly the documented shape + if len(stub.bodies) != 1 { + t.Fatalf("batches: %d", len(stub.bodies)) + } + want := `{"source":"gable","yard":{"name":"Kelowna Main Yard","town":"Kelowna, BC"},` + + `"store":{"id":"1234","zip":"99201","label":"Home Depot, Spokane WA: nearest store with live prices"},` + + `"bandPercent":5,"items":[` + + `{"sku":"DL_SPF_2X4_L08_S","name":"Desc DL_SPF_2X4_L08_S","category":"Lumber","unit":"PCS","price":5.42},` + + `{"sku":"DL_SPF_2X6_L08_S","name":"Desc DL_SPF_2X6_L08_S","category":"Lumber","unit":"PCS","price":9}]}` + var gotJSON, wantJSON any + _ = json.Unmarshal([]byte(stub.bodies[0]), &gotJSON) + _ = json.Unmarshal([]byte(want), &wantJSON) + if fmt.Sprint(gotJSON) != fmt.Sprint(wantJSON) { + t.Fatalf("body\n got %s\nwant %s", stub.bodies[0], want) + } + if strings.Contains(stub.bodies[0], "cost") || strings.Contains(stub.bodies[0], "300") || strings.Contains(stub.bodies[0], "700") { + t.Fatalf("the request leaks a cost: %s", stub.bodies[0]) + } + if stub.paths[0] != "POST /api/v1/webhooks/"+testHook+"/sync application/json" { + t.Fatalf("call: %q", stub.paths[0]) + } + // the answer + if res.FlowName != "Pricing flow" || res.StoreSeen == nil || *res.StoreSeen != "Spokane Store" || + res.BandPct != "5" || res.MarginFloorPct != "15" { + t.Fatalf("header: %+v", res) + } + if len(res.Rows) != 2 { + t.Fatalf("rows (the unknown sku must be ignored): %+v", res.Rows) + } + r := rowOf(t, res, "DL_SPF_2X4_L08_S") + if r.Status != "matched" || r.Competitor == nil || r.Competitor.PriceCents != 400 || + r.Competitor.ListedPriceCents != 4000 || r.Competitor.Confidence != 100 || + r.Competitor.Link != "https://www.homedepot.com/p/1" || + r.Competitor.UnitNote != "Sold as a pack of 10; price divided by 10" { + t.Fatalf("matched row: %+v %+v", r, r.Competitor) + } + // ours 542 vs 400: +35.5 percent, above; suggestion max(floor(400*1.05)=420, ceil(300/.85)=353) = 420 + if r.OurPriceCents != 542 || r.DiffPct == nil || *r.DiffPct != 35.5 || r.Position != "above" || + r.SuggestedPriceCents == nil || *r.SuggestedPriceCents != 420 || r.Reason != "lower_to_band" || !r.CostKnown { + t.Fatalf("our figures: %+v", r) + } + u := rowOf(t, res, "DL_SPF_2X6_L08_S") + if u.Status != "unmatched" || u.Competitor != nil || u.Note != "No equivalent found" || u.OurPriceCents != 900 { + t.Fatalf("unmatched row: %+v", u) + } + out, _ := json.Marshal(res) + if strings.Contains(string(out), testHook) || strings.Contains(string(out), stub.URL) { + t.Fatalf("the answer leaks the id or the address: %s", out) + } +} + +func TestRun_NoCostGivesNoSuggestion(t *testing.T) { + a := product("SKU_A", 1000, 0) + stub := newFlow(t, matchAll(8.00)) + svc, _, _ := configured(t, stub, catalogOf(a), nil) + res, err := svc.Run(context.Background(), ids(a)) + if err != nil { + t.Fatal(err) + } + r := res.Rows[0] + if r.CostKnown || r.SuggestedPriceCents != nil || r.Note != "No cost on file, so no price is suggested" { + t.Fatalf("%+v", r) + } +} + +func TestRun_BadRowsAreErrors(t *testing.T) { + ps := []marketcheck.Product{product("S1", 500, 100), product("S2", 500, 100), product("S3", 500, 100)} + stub := newFlow(t, func(w http.ResponseWriter, r *http.Request, body string) { + _, _ = fmt.Fprint(w, `{"store":null,"rows":[ + {"sku":"S1","status":"matched","match":{"title":"neg","theirPrice":-3,"listedPrice":1,"link":"","confidence":5,"normNote":""}}, + {"sku":"S2","status":"matched","match":{"title":"huge","theirPrice":100000,"listedPrice":1,"link":"","confidence":5,"normNote":""}}, + {"sku":"S3","status":"matched","match":{"title":"ok\u0007bell","theirPrice":4.5,"listedPrice":4.5,"link":"https://evil.example/p","confidence":-4,"normNote":""}}]}`) + }) + svc, _, _ := configured(t, stub, catalogOf(ps...), nil) + res, err := svc.Run(context.Background(), ids(ps...)) + if err != nil { + t.Fatal(err) + } + if res.StoreSeen != nil { + t.Fatalf("store_seen should be null: %v", *res.StoreSeen) + } + if rowOf(t, res, "S1").Status != "error" || rowOf(t, res, "S2").Status != "error" { + t.Fatalf("out of range prices must be error rows: %+v", res.Rows) + } + s3 := rowOf(t, res, "S3") + if s3.Status != "matched" || s3.Competitor.Link != "" || s3.Competitor.Confidence != 0 || + strings.ContainsRune(s3.Competitor.Title, 7) { + t.Fatalf("sanitised row: %+v %+v", s3, s3.Competitor) + } +} + +func TestRun_ItemWithNoRowIsAnError(t *testing.T) { + a, b := product("S1", 500, 100), product("S2", 500, 100) + stub := newFlow(t, func(w http.ResponseWriter, r *http.Request, body string) { + _, _ = fmt.Fprint(w, `{"store":null,"rows":[{"sku":"S1","status":"matched","match":{"title":"t","theirPrice":5,"listedPrice":5,"link":"","confidence":50,"normNote":""}}]}`) + }) + svc, _, _ := configured(t, stub, catalogOf(a, b), nil) + res, err := svc.Run(context.Background(), ids(a, b)) + if err != nil { + t.Fatal(err) + } + r := rowOf(t, res, "S2") + if r.Status != "error" || r.Note != "The flow did not return this item" { + t.Fatalf("%+v", r) + } +} + +func TestRun_OneBatchFailingOthersStand(t *testing.T) { + var ps []marketcheck.Product + for i := 0; i < 7; i++ { + ps = append(ps, product(fmt.Sprintf("SKU_%d", i), 1000, 100)) + } + stub := newFlow(t, func(w http.ResponseWriter, r *http.Request, body string) { + if strings.Contains(body, "SKU_3") { // the second batch + http.Error(w, "boom", http.StatusInternalServerError) + return + } + matchAll(9)(w, r, body) + }) + svc, _, _ := configured(t, stub, catalogOf(ps...), nil) + res, err := svc.Run(context.Background(), ids(ps...)) + if err != nil { + t.Fatal(err) + } + if len(stub.bodies) != 3 { + t.Fatalf("batches of up to 3: got %d calls", len(stub.bodies)) + } + for i, r := range res.Rows { + inBad := i >= 3 && i <= 5 + if inBad && (r.Status != "error" || r.Note != "FB Connect did not answer") { + t.Fatalf("row %d: %+v", i, r) + } + if !inBad && r.Status != "matched" { + t.Fatalf("row %d should stand: %+v", i, r) + } + } +} + +func TestRun_FailureWords(t *testing.T) { + cases := map[string]struct { + status int + body string + note string + }{ + "404": {404, "{}", "No flow answers at that webhook id: check it in Tech Admin"}, + "400": {400, "{}", "No flow answers at that webhook id: check it in Tech Admin"}, + "408": {408, "{}", "FB Connect timed out waiting for the run"}, + "500": {500, "{}", "FB Connect did not answer"}, + "badjs": {200, "{not json", "FB Connect did not answer"}, + "302": {302, "", "FB Connect did not answer"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + a := product("S1", 500, 100) + redirected := false + target := newFlow(t, func(w http.ResponseWriter, r *http.Request, b string) { redirected = true }) + stub := newFlow(t, func(w http.ResponseWriter, r *http.Request, body string) { + if c.status == 302 { + http.Redirect(w, r, target.URL+"/elsewhere", http.StatusFound) + return + } + w.WriteHeader(c.status) + _, _ = fmt.Fprint(w, c.body) + }) + svc, _, _ := configured(t, stub, catalogOf(a), nil) + res, err := svc.Run(context.Background(), ids(a)) + if err != nil { + t.Fatal(err) + } + if r := res.Rows[0]; r.Status != "error" || r.Note != c.note { + t.Fatalf("%+v", r) + } + if redirected { + t.Fatal("a redirect was followed") + } + }) + } +} + +func TestRun_TimeoutIsTimedOutWords(t *testing.T) { + a := product("S1", 500, 100) + stub := newFlow(t, func(w http.ResponseWriter, r *http.Request, body string) { + time.Sleep(400 * time.Millisecond) + }) + st := newMemStore() + svc := marketcheck.NewService(st, catalogOf(a)).WithVault(fakeVault{present: true}).WithTxRunner(passTx{}). + WithBatchTimeout(50 * time.Millisecond) + if _, err := svc.SaveSettings(context.Background(), marketcheck.SettingsInput{ConnectURL: ptr(stub.URL), WebhookID: ptr(testHook)}); err != nil { + t.Fatal(err) + } + res, err := svc.Run(context.Background(), ids(a)) + if err != nil { + t.Fatal(err) + } + if r := res.Rows[0]; r.Status != "error" || r.Note != "FB Connect timed out waiting for the run" { + t.Fatalf("%+v", r) + } +} + +func TestRun_OversizedAnswerRefused(t *testing.T) { + a := product("S1", 500, 100) + stub := newFlow(t, func(w http.ResponseWriter, r *http.Request, body string) { + _, _ = fmt.Fprintf(w, `{"store":null,"rows":[],"pad":"%s"}`, strings.Repeat("x", 1<<20+10)) + }) + svc, _, _ := configured(t, stub, catalogOf(a), nil) + res, err := svc.Run(context.Background(), ids(a)) + if err != nil { + t.Fatal(err) + } + if r := res.Rows[0]; r.Status != "error" || r.Note != "FB Connect did not answer" { + t.Fatalf("%+v", r) + } +} + +func TestRun_PickBounds(t *testing.T) { + a := product("S1", 500, 100) + stub := newFlow(t, matchAll(5)) + svc, _, _ := configured(t, stub, catalogOf(a), nil) + if _, err := svc.Run(context.Background(), nil); err == nil { + t.Fatal("empty pick accepted") + } + if _, err := svc.Run(context.Background(), []uuid.UUID{a.ID, a.ID}); err == nil { + t.Fatal("duplicate pick accepted") + } + var many []uuid.UUID + for i := 0; i < 13; i++ { + many = append(many, uuid.New()) + } + if _, err := svc.Run(context.Background(), many); err == nil { + t.Fatal("13 accepted") + } +} + +func TestRun_UnknownProductIsAnErrorRow(t *testing.T) { + stub := newFlow(t, matchAll(5)) + svc, _, _ := configured(t, stub, catalogOf(), nil) + res, err := svc.Run(context.Background(), []uuid.UUID{uuid.New()}) + if err != nil { + t.Fatal(err) + } + if r := res.Rows[0]; r.Status != "error" || len(stub.bodies) != 0 { + t.Fatalf("%+v calls %d", r, len(stub.bodies)) + } +} + +func TestRun_LogsCarryNeitherTheIDNorTheAddress(t *testing.T) { + a := product("S1", 500, 100) + for _, status := range []int{200, 500, 404} { + stub := newFlow(t, func(w http.ResponseWriter, r *http.Request, body string) { + if status != 200 { + w.WriteHeader(status) + return + } + matchAll(5)(w, r, body) + }) + svc, _, cap := configured(t, stub, catalogOf(a), nil) + res, err := svc.Run(context.Background(), ids(a)) + if err != nil { + t.Fatal(err) + } + out, _ := json.Marshal(res) + all := cap.String() + string(out) + if strings.Contains(all, testHook) || strings.Contains(all, "/api/v1/webhooks") || strings.Contains(all, stub.URL) { + t.Fatalf("status %d leaks:\n%s", status, all) + } + if !strings.Contains(cap.String(), "127.0.0.1") { + t.Fatalf("the batch outcome log lacks the host: %q", cap.String()) + } + } + // a dial failure's error text carries the URL; it must not be logged or answered + svc := marketcheck.NewService(newMemStore(), catalogOf(a)).WithVault(fakeVault{present: true}).WithTxRunner(passTx{}) + lg, cap := newLogger() + svc.WithLogger(lg) + if _, err := svc.SaveSettings(context.Background(), marketcheck.SettingsInput{ + ConnectURL: ptr("http://127.0.0.1:1"), WebhookID: ptr(testHook)}); err != nil { + t.Fatal(err) + } + res, _ := svc.Run(context.Background(), ids(a)) + out, _ := json.Marshal(res) + if strings.Contains(cap.String()+string(out), testHook) { + t.Fatalf("dial failure leaks the id: %s %s", cap.String(), out) + } +} diff --git a/core/internal/marketcheck/settings_test.go b/core/internal/marketcheck/settings_test.go new file mode 100644 index 000000000..4d9f7c9f8 --- /dev/null +++ b/core/internal/marketcheck/settings_test.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package marketcheck_test + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/marketcheck" +) + +func newSvc(vault bool) (*marketcheck.Service, *memStore) { + st := newMemStore() + return marketcheck.NewService(st, catalogOf()).WithVault(fakeVault{present: vault}).WithTxRunner(passTx{}), st +} + +func TestSettings_DefaultsWhenUnset(t *testing.T) { + svc, _ := newSvc(true) + got, err := svc.Settings(context.Background()) + if err != nil { + t.Fatal(err) + } + if got.Configured || got.WebhookIDSaved || got.FlowName != "FB Connect pricing flow" || + got.StoreLabel != "" || got.BandPct != "5" || got.MarginFloorPct != "15" { + t.Fatalf("defaults: %+v", got) + } +} + +func TestSettings_RoundTripNeverAnswersTheID(t *testing.T) { + svc, st := newSvc(true) + ctx := context.Background() + in := marketcheck.SettingsInput{ConnectURL: ptr("https://connect.example.test"), WebhookID: ptr(testHook), + FlowName: ptr("Pricing flow"), StoreLabel: ptr("Home Depot, Spokane WA"), StoreID: ptr("1234"), + StoreZip: ptr("99201"), BandPct: ptr("7.5"), MarginFloorPct: ptr("20")} + got, err := svc.SaveSettings(ctx, in) + if err != nil { + t.Fatal(err) + } + if !got.Configured || !got.WebhookIDSaved || got.ConnectURL != "https://connect.example.test" || + got.FlowName != "Pricing flow" || got.StoreID != "1234" || got.StoreZip != "99201" || + got.BandPct != "7.5" || got.MarginFloorPct != "20" { + t.Fatalf("saved: %+v", got) + } + b, _ := json.Marshal(got) + if strings.Contains(string(b), testHook) || strings.Contains(string(b), "webhook_id\"") { + t.Fatalf("the answer carries the id: %s", b) + } + if !strings.Contains(string(b), `"webhook_id_saved":true`) { + t.Fatalf("answer lacks the saved flag: %s", b) + } + // sealed at rest + raw := st.rows["market_check.webhook_id"] + if !strings.HasPrefix(raw, "enc:v1:") || strings.Contains(raw, testHook) { + t.Fatalf("stored value is not sealed: %q", raw) + } + // a PUT with webhook_id absent keeps the id; an empty string clears it + got, err = svc.SaveSettings(ctx, marketcheck.SettingsInput{FlowName: ptr("Renamed")}) + if err != nil || !got.WebhookIDSaved || got.FlowName != "Renamed" || got.ConnectURL == "" { + t.Fatalf("keep: %+v %v", got, err) + } + got, err = svc.SaveSettings(ctx, marketcheck.SettingsInput{WebhookID: ptr("")}) + if err != nil || got.WebhookIDSaved || got.Configured { + t.Fatalf("clear: %+v %v", got, err) + } + if _, ok := st.rows["market_check.webhook_id"]; ok { + t.Fatal("the cleared id is still stored") + } +} + +func TestSettings_RefusedWithNoVaultKey(t *testing.T) { + svc, st := newSvc(false) + _, err := svc.SaveSettings(context.Background(), marketcheck.SettingsInput{ + ConnectURL: ptr("https://connect.example.test"), WebhookID: ptr(testHook)}) + if err == nil || !strings.Contains(err.Error(), "vault") { + t.Fatalf("want a refusal naming the vault, got %v", err) + } + for k, v := range st.rows { + if strings.Contains(v, testHook) { + t.Fatalf("id stored plain under %s", k) + } + } + if len(st.rows) != 0 { + t.Fatalf("a refused save wrote rows: %v", st.rows) + } +} + +func TestSettings_Validation(t *testing.T) { + bad := map[string]marketcheck.SettingsInput{ + "connect_url": {ConnectURL: ptr("http://connect.example.test")}, + "connect_url ": {ConnectURL: ptr("https://user:pw@connect.example.test")}, + "connect_url ": {ConnectURL: ptr("https://connect.example.test/api")}, + "connect_url ": {ConnectURL: ptr("https://connect.example.test/?a=b")}, + "connect_url ": {ConnectURL: ptr("https://connect.example.test/#frag")}, + "connect_url ": {ConnectURL: ptr("ftp://connect.example.test")}, + "webhook_id": {WebhookID: ptr("short")}, + "webhook_id ": {WebhookID: ptr("has space in it")}, + "webhook_id ": {WebhookID: ptr(strings.Repeat("a", 65))}, + "store_id": {StoreID: ptr("123456789")}, + "store_id ": {StoreID: ptr("12ab")}, + "store_zip": {StoreZip: ptr("9920")}, + "store_zip ": {StoreZip: ptr("99a01")}, + "band_pct": {BandPct: ptr("51")}, + "band_pct ": {BandPct: ptr("-1")}, + "band_pct ": {BandPct: ptr("five")}, + "margin_floor_pct": {MarginFloorPct: ptr("91")}, + "flow_name": {FlowName: ptr(strings.Repeat("x", 121))}, + "store_label": {StoreLabel: ptr("line\nbreak")}, + } + for name, in := range bad { + t.Run(name, func(t *testing.T) { + svc, st := newSvc(true) + _, err := svc.SaveSettings(context.Background(), in) + if err == nil { + t.Fatal("accepted") + } + want := strings.TrimSpace(name) + if !strings.Contains(err.Error(), want) && !strings.Contains(marketcheck.FieldOf(err), want) { + t.Fatalf("error does not name %s: %v (field %q)", want, err, marketcheck.FieldOf(err)) + } + if len(st.rows) != 0 { + t.Fatalf("a refused save wrote rows: %v", st.rows) + } + }) + } + ok := []marketcheck.SettingsInput{ + {ConnectURL: ptr("http://127.0.0.1:8123")}, + {ConnectURL: ptr("http://localhost:8123/")}, + {ConnectURL: ptr("http://[::1]:8123")}, + {ConnectURL: ptr("https://connect.example.test:8443")}, + {StoreID: ptr("")}, {StoreZip: ptr("")}, {BandPct: ptr("0")}, {BandPct: ptr("50")}, {MarginFloorPct: ptr("90")}, + } + for _, in := range ok { + svc, _ := newSvc(true) + if _, err := svc.SaveSettings(context.Background(), in); err != nil { + t.Fatalf("%+v refused: %v", in, err) + } + } +} From ce37c6e3534f90c835172bcc912b645458c281be Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 08:28:51 -0700 Subject: [PATCH 02/17] marketcheck: settings, the suggestion rule and the run against the flow Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/marketcheck/compute.go | 94 ++++++ core/internal/marketcheck/helpers_test.go | 2 +- core/internal/marketcheck/run.go | 393 ++++++++++++++++++++++ core/internal/marketcheck/service.go | 111 ++++++ core/internal/marketcheck/settings.go | 330 ++++++++++++++++++ 5 files changed, 929 insertions(+), 1 deletion(-) create mode 100644 core/internal/marketcheck/compute.go create mode 100644 core/internal/marketcheck/run.go create mode 100644 core/internal/marketcheck/service.go create mode 100644 core/internal/marketcheck/settings.go diff --git a/core/internal/marketcheck/compute.go b/core/internal/marketcheck/compute.go new file mode 100644 index 000000000..b40cd4cbc --- /dev/null +++ b/core/internal/marketcheck/compute.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package marketcheck + +import "math" + +// Money is integer cents throughout. The one rounding rule: a price taken from +// a dollar figure is rounded to the nearest cent (roundCents); the margin floor +// is rounded UP to the next cent (so the margin is never under the floor); a +// band target is rounded DOWN to the cent (floorDiv). Percents are held as +// hundredths of a percent ("basis points") so none of this touches a float. + +// Reasons for a suggested price. +const ( + ReasonLowerToBand = "lower_to_band" + ReasonRaiseToBand = "raise_to_band" + ReasonHeldAtFloor = "held_at_floor" + ReasonKeep = "keep" + ReasonNoCost = "no_cost" +) + +// Positions of our price against the competitor's. +const ( + PositionAbove = "above" + PositionBelow = "below" + PositionInLine = "in_line" +) + +// Suggestion is the server's own reading of one matched row. +type Suggestion struct { + DiffPct float64 + Position string + SuggestedCents int64 // 0 when SuggestedKnown is false + SuggestedKnown bool + Reason string +} + +// roundCents converts a dollar figure to the nearest cent. +func roundCents(dollars float64) int64 { return int64(math.Round(dollars * 100)) } + +// Suggest computes the difference, the position and the suggested price. ours, +// theirs and cost are cents; bandBP and floorBP are hundredths of a percent. +// theirs must be above zero. +func Suggest(ours, theirs, cost, bandBP, floorBP int64) Suggestion { + s := Suggestion{} + diff := float64(ours-theirs) / float64(theirs) * 100 + s.DiffPct = math.Round(diff*10) / 10 + if s.DiffPct == 0 { + s.DiffPct = 0 // no negative zero + } + switch { + case (ours-theirs)*10000 > bandBP*theirs: + s.Position = PositionAbove + case (theirs-ours)*10000 > bandBP*theirs: + s.Position = PositionBelow + default: + s.Position = PositionInLine + } + if cost <= 0 { + s.Reason = ReasonNoCost + return s + } + // floor = ceil(cost / (1 - floor%)) = ceil(cost * 10000 / (10000 - floorBP)) + floor := ceilDiv(cost*10000, 10000-floorBP) + s.SuggestedKnown = true + switch s.Position { + case PositionAbove: + target := theirs * (10000 + bandBP) / 10000 + s.SuggestedCents, s.Reason = target, ReasonLowerToBand + if target < floor { + s.SuggestedCents, s.Reason = floor, ReasonHeldAtFloor + } + case PositionBelow: + target := theirs * (10000 - bandBP) / 10000 + s.SuggestedCents, s.Reason = target, ReasonRaiseToBand + if target < floor { + s.SuggestedCents = floor + } + default: + s.SuggestedCents, s.Reason = ours, ReasonKeep + if ours < floor { + s.SuggestedCents, s.Reason = floor, ReasonHeldAtFloor + } + } + return s +} + +func ceilDiv(a, b int64) int64 { + if b <= 0 { + return a + } + return (a + b - 1) / b +} diff --git a/core/internal/marketcheck/helpers_test.go b/core/internal/marketcheck/helpers_test.go index 90cd1b5c2..478bbce59 100644 --- a/core/internal/marketcheck/helpers_test.go +++ b/core/internal/marketcheck/helpers_test.go @@ -128,7 +128,7 @@ func newLogger() (*slog.Logger, *logCapture) { } // configured returns a service pointed at the stub, with settings saved. -func configured(t *testing.T, stub *httptest.Server, cat *memCatalog, mod func(*marketcheck.SettingsInput)) (*marketcheck.Service, *memStore, *logCapture) { +func configured(t *testing.T, stub *flowStub, cat *memCatalog, mod func(*marketcheck.SettingsInput)) (*marketcheck.Service, *memStore, *logCapture) { t.Helper() st := newMemStore() lg, cap := newLogger() diff --git a/core/internal/marketcheck/run.go b/core/internal/marketcheck/run.go new file mode 100644 index 000000000..8eff49f5c --- /dev/null +++ b/core/internal/marketcheck/run.go @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package marketcheck + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "net/url" + "strings" + "sync" + "unicode" + + "github.com/gablelbm/gable/internal/ai" + "github.com/google/uuid" +) + +const ( + rowMatched = "matched" + rowUnmatched = "unmatched" + rowError = "error" + + noteDidNotAnswer = "FB Connect did not answer" + noteBadHook = "No flow answers at that webhook id: check it in Tech Admin" + noteTimedOut = "FB Connect timed out waiting for the run" + noteNoRow = "The flow did not return this item" + noteNoCost = "No cost on file, so no price is suggested" + noteNoPrice = "No walk in price is on file for this product" + noteNoProduct = "This product was not found" + + allowedLinkPrefix = "https://www.homedepot.com/" +) + +// Competitor is the flow's match for one item, cleaned. +type Competitor struct { + Title string `json:"title"` + PriceCents int64 `json:"price_cents"` + ListedPriceCents int64 `json:"listed_price_cents"` + Link string `json:"link"` + Confidence int `json:"confidence"` + UnitNote string `json:"unit_note"` +} + +// Row is one picked product's result. +type Row struct { + ProductID uuid.UUID `json:"product_id"` + SKU string `json:"sku"` + Description string `json:"description"` + UOM string `json:"uom"` + OurPriceCents int64 `json:"our_price_cents"` + CostKnown bool `json:"cost_known"` + Status string `json:"status"` + Competitor *Competitor `json:"competitor"` + DiffPct *float64 `json:"diff_pct"` + Position string `json:"position"` + SuggestedPriceCents *int64 `json:"suggested_price_cents"` + Reason string `json:"reason"` + Note string `json:"note"` +} + +// Result is the run's answer. Cost itself is never in it. +type Result struct { + FlowName string `json:"flow_name"` + StoreLabel string `json:"store_label"` + StoreSeen *string `json:"store_seen"` + BandPct string `json:"band_pct"` + MarginFloorPct string `json:"margin_floor_pct"` + Rows []Row `json:"rows"` +} + +// The request to the flow (docs/integrations/MARKET-CHECK-FLOW.md). +type flowRequest struct { + Source string `json:"source"` + Yard flowYard `json:"yard"` + Store flowStore `json:"store"` + BandPercent json.Number `json:"bandPercent"` + Items []flowItemOut `json:"items"` +} +type flowYard struct { + Name string `json:"name"` + Town string `json:"town"` +} +type flowStore struct { + ID string `json:"id"` + Zip string `json:"zip"` + Label string `json:"label"` +} +type flowItemOut struct { + SKU string `json:"sku"` + Name string `json:"name"` + Category string `json:"category"` + Unit string `json:"unit"` + Price json.Number `json:"price"` +} + +// The flow's answer, strict on types; unknown fields are ignored. +type flowAnswer struct { + Store *struct { + Name string `json:"name"` + } `json:"store"` + Rows []flowRow `json:"rows"` +} +type flowRow struct { + SKU string `json:"sku"` + Status string `json:"status"` + Match *struct { + Title string `json:"title"` + TheirPrice float64 `json:"theirPrice"` + ListedPrice float64 `json:"listedPrice"` + Link string `json:"link"` + Confidence float64 `json:"confidence"` + NormNote string `json:"normNote"` + } `json:"match"` + Note string `json:"note"` +} + +type batchOutcome struct { + rows map[string]flowRow + store string + note string // set when the batch failed +} + +func cleanText(s string, max int) string { + s = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, s) + s = strings.TrimSpace(s) + if r := []rune(s); len(r) > max { + s = string(r[:max]) + } + return s +} + +func dollars(cents int64) json.Number { + return json.Number(fmt.Sprintf("%d.%02d", cents/100, cents%100)) +} + +// Run checks the picked products against the flow. +func (s *Service) Run(ctx context.Context, productIDs []uuid.UUID) (*Result, error) { + if len(productIDs) < 1 || len(productIDs) > maxPick { + return nil, invalidField("product_ids", "pick between 1 and 12 products") + } + seen := map[uuid.UUID]bool{} + for _, id := range productIDs { + if seen[id] { + return nil, invalidField("product_ids", "each product may be picked once") + } + seen[id] = true + } + cfg, err := s.load(ctx) + if err != nil { + return nil, err + } + if !cfg.Configured { + return nil, ErrNotConfigured + } + hook, err := ai.OpenValue(s.vault, cfg.sealedID) + if err != nil || hook == "" { + // Saved but unreadable (the vault key changed): treat as not configured. + return nil, ErrNotConfigured + } + bandBP, _, err := parsePercent("band_pct", cfg.BandPct, 50) + if err != nil { + return nil, err + } + floorBP, _, err := parsePercent("margin_floor_pct", cfg.MarginFloorPct, 90) + if err != nil { + return nil, err + } + yard, err := s.catalog.Yard(ctx) + if err != nil { + return nil, err + } + products, err := s.catalog.Products(ctx, productIDs) + if err != nil { + return nil, err + } + + res := &Result{FlowName: cfg.FlowName, StoreLabel: cfg.StoreLabel, BandPct: cfg.BandPct, + MarginFloorPct: cfg.MarginFloorPct, Rows: make([]Row, len(productIDs))} + var sendIdx []int + for i, id := range productIDs { + p, ok := products[id] + row := Row{ProductID: id, Status: rowError} + switch { + case !ok: + row.Note = noteNoProduct + case p.PriceCents <= 0: + row.SKU, row.Description, row.UOM = p.SKU, p.Description, p.UOM + row.CostKnown = p.CostCents > 0 + row.Note = noteNoPrice + default: + row.SKU, row.Description, row.UOM = p.SKU, p.Description, p.UOM + row.OurPriceCents, row.CostKnown = p.PriceCents, p.CostCents > 0 + sendIdx = append(sendIdx, i) + } + res.Rows[i] = row + } + + // Batches of up to 3 items, at most 4 at once. + var batches [][]int + for i := 0; i < len(sendIdx); i += batchSize { + end := i + batchSize + if end > len(sendIdx) { + end = len(sendIdx) + } + batches = append(batches, sendIdx[i:end]) + } + outcomes := make([]batchOutcome, len(batches)) + var wg sync.WaitGroup + sem := make(chan struct{}, maxConcurrent) + for bi := range batches { + wg.Add(1) + go func(bi int) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + items := make([]flowItemOut, 0, len(batches[bi])) + for _, idx := range batches[bi] { + p := products[productIDs[idx]] + items = append(items, flowItemOut{SKU: p.SKU, Name: p.Description, Category: p.Category, + Unit: p.UOM, Price: dollars(p.PriceCents)}) + } + req := flowRequest{Source: "gable", Yard: flowYard{Name: yard.Name, Town: yard.Town}, + Store: flowStore{ID: cfg.StoreID, Zip: cfg.StoreZip, Label: cfg.StoreLabel}, + BandPercent: json.Number(cfg.BandPct), Items: items} + outcomes[bi] = s.callBatch(ctx, cfg.ConnectURL, hook, req) + }(bi) + } + wg.Wait() + + for bi, b := range batches { + out := outcomes[bi] + if out.store != "" && res.StoreSeen == nil { + st := out.store + res.StoreSeen = &st + } + for _, idx := range b { + row := &res.Rows[idx] + if out.note != "" { + row.Note = out.note + continue + } + fr, ok := out.rows[row.SKU] + if !ok { + row.Note = noteNoRow + continue + } + applyRow(row, fr, bandBP, floorBP, products[row.ProductID].CostCents) + } + } + return res, nil +} + +func applyRow(row *Row, fr flowRow, bandBP, floorBP, cost int64) { + switch fr.Status { + case rowUnmatched: + row.Status = rowUnmatched + row.Note = cleanText(fr.Note, 300) + return + case rowMatched: + default: + row.Note = cleanText(fr.Note, 300) + if row.Note == "" { + row.Note = noteDidNotAnswer + } + return + } + m := fr.Match + if m == nil || !validPrice(m.TheirPrice) { + row.Note = "The flow's price for this item could not be used" + return + } + listed := m.ListedPrice + if !validPrice(listed) { + listed = m.TheirPrice + } + c := &Competitor{ + Title: cleanText(m.Title, 200), PriceCents: roundCents(m.TheirPrice), ListedPriceCents: roundCents(listed), + Confidence: int(math.Round(math.Min(100, math.Max(0, m.Confidence)))), + UnitNote: cleanText(m.NormNote, 300), + } + if strings.HasPrefix(m.Link, allowedLinkPrefix) { + if u, err := url.Parse(m.Link); err == nil && u.Host == "www.homedepot.com" { + c.Link = m.Link + } + } + if c.PriceCents <= 0 { + row.Note = "The flow's price for this item could not be used" + return + } + row.Status, row.Competitor = rowMatched, c + row.Note = cleanText(fr.Note, 300) + sg := Suggest(row.OurPriceCents, c.PriceCents, cost, bandBP, floorBP) + d := sg.DiffPct + row.DiffPct, row.Position, row.Reason = &d, sg.Position, sg.Reason + if sg.SuggestedKnown { + v := sg.SuggestedCents + row.SuggestedPriceCents = &v + } else if row.Note == "" { + row.Note = noteNoCost + } +} + +func validPrice(p float64) bool { + return !math.IsNaN(p) && !math.IsInf(p, 0) && p > 0 && p < 100000 +} + +// callBatch posts one batch. Its failures are fixed words: the transport's +// own error text holds the address, so it is never kept, logged or answered. +func (s *Service) callBatch(ctx context.Context, base, hook string, body flowRequest) batchOutcome { + host := hostOf(base) + fail := func(note, kind string) batchOutcome { + s.log.Warn("market check batch failed", "host", host, "kind", kind, "items", len(body.Items)) + return batchOutcome{note: note} + } + payload, err := json.Marshal(body) + if err != nil { + return fail(noteDidNotAnswer, "encode") + } + cctx, cancel := context.WithTimeout(ctx, s.timeout) + defer cancel() + req, err := http.NewRequestWithContext(cctx, http.MethodPost, base+"/api/v1/webhooks/"+hook+"/sync", bytes.NewReader(payload)) + if err != nil { + return fail(noteDidNotAnswer, "request") + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.client.Do(req) + if err != nil { + if errors.Is(cctx.Err(), context.DeadlineExceeded) { + return fail(noteTimedOut, "timeout") + } + return fail(noteDidNotAnswer, "transport") + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNotFound, http.StatusBadRequest: + return fail(noteBadHook, "status_404_400") + case http.StatusRequestTimeout: + return fail(noteTimedOut, "status_408") + default: + return fail(noteDidNotAnswer, "status") + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxAnswerBytes+1)) + if err != nil { + if errors.Is(cctx.Err(), context.DeadlineExceeded) { + return fail(noteTimedOut, "timeout") + } + return fail(noteDidNotAnswer, "read") + } + if len(raw) > maxAnswerBytes { + return fail(noteDidNotAnswer, "too_large") + } + var ans flowAnswer + if err := json.Unmarshal(raw, &ans); err != nil { + return fail(noteDidNotAnswer, "decode") + } + out := batchOutcome{rows: map[string]flowRow{}} + if ans.Store != nil { + out.store = cleanText(ans.Store.Name, 120) + } + sent := map[string]bool{} + for _, it := range body.Items { + sent[it.SKU] = true + } + for _, r := range ans.Rows { + if sent[r.SKU] { // a SKU we did not send is ignored + if _, dup := out.rows[r.SKU]; !dup { + out.rows[r.SKU] = r + } + } + } + s.log.Info("market check batch answered", "host", host, "items", len(body.Items), "rows", len(out.rows)) + return out +} + +func hostOf(base string) string { + u, err := url.Parse(base) + if err != nil { + return "unknown" + } + return u.Host +} diff --git a/core/internal/marketcheck/service.go b/core/internal/marketcheck/service.go new file mode 100644 index 000000000..ad328fd80 --- /dev/null +++ b/core/internal/marketcheck/service.go @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +// Package marketcheck is the Market check: our walk in prices read against a +// live competitor store through an FB Connect pricing flow (FROM-PRINCIPAL +// item 112 c). The flow is reached by its webhook, whose id is a secret +// (anyone holding it can run the flow): it is sealed at rest, never answered, +// never logged. The flow's rows are untrusted; every figure the dealer sees +// beside the competitor's price is recomputed here. +package marketcheck + +import ( + "context" + "log/slog" + "net/http" + "time" + + "github.com/gablelbm/gable/internal/ai" + "github.com/google/uuid" +) + +const ( + maxPick = 12 + batchSize = 3 + maxConcurrent = 4 + maxAnswerBytes = 1 << 20 +) + +// Store reads and writes the module's system_settings rows. +type Store interface { + Read(ctx context.Context, key string) (string, bool, error) + Write(ctx context.Context, key, value string) error + Delete(ctx context.Context, key string) error +} + +// TxRunner runs fn in one transaction (database.DB). +type TxRunner interface { + RunInTx(ctx context.Context, fn func(ctx context.Context) error) error +} + +// Auditor records a settings change by field name, never by value. +type Auditor interface { + Record(ctx context.Context, action string, fields []string) error +} + +// Yard is the caller's yard as the flow is told it. +type Yard struct { + Name string + Town string +} + +// Product is what the run reads of one catalogue product: our walk in price +// and our average unit cost, both cents. +type Product struct { + ID uuid.UUID + SKU string + Description string + Category string + UOM string + PriceCents int64 + CostCents int64 +} + +// Catalog is the read side of the run. +type Catalog interface { + Yard(ctx context.Context) (Yard, error) + Products(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]Product, error) +} + +// Service holds the settings and runs checks. +type Service struct { + store Store + catalog Catalog + vault ai.Sealer + tx TxRunner + audit Auditor + log *slog.Logger + client *http.Client + timeout time.Duration +} + +// NewService builds the service; settings rows live in store. +func NewService(store Store, catalog Catalog) *Service { + return &Service{ + store: store, catalog: catalog, log: slog.Default(), timeout: 30 * time.Second, + client: &http.Client{ + // A redirect is never followed: the answer is the 3xx itself, a failed batch. + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }, + } +} + +// WithVault wires the vault that seals the webhook id at rest. +func (s *Service) WithVault(v ai.Sealer) *Service { s.vault = v; return s } + +// WithTxRunner wires the transaction runner the settings write uses. +func (s *Service) WithTxRunner(t TxRunner) *Service { s.tx = t; return s } + +// WithAudit wires the settings change recorder. +func (s *Service) WithAudit(a Auditor) *Service { s.audit = a; return s } + +// WithLogger wires the logger (host only lines; never the id or the address). +func (s *Service) WithLogger(l *slog.Logger) *Service { + if l != nil { + s.log = l + } + return s +} + +// WithBatchTimeout shortens the per batch context timeout (tests). +func (s *Service) WithBatchTimeout(d time.Duration) *Service { s.timeout = d; return s } diff --git a/core/internal/marketcheck/settings.go b/core/internal/marketcheck/settings.go new file mode 100644 index 000000000..fd4288583 --- /dev/null +++ b/core/internal/marketcheck/settings.go @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package marketcheck + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "regexp" + "strconv" + "strings" + "unicode" + + "github.com/gablelbm/gable/internal/ai" + "github.com/gablelbm/gable/internal/platform/httpx" +) + +// The one place the setting keys are spelled. +const ( + keyPrefix = "market_check." + keyConnectURL = keyPrefix + "connect_url" + keyWebhookID = keyPrefix + "webhook_id" + keyFlowName = keyPrefix + "flow_name" + keyStoreLabel = keyPrefix + "store_label" + keyStoreID = keyPrefix + "store_id" + keyStoreZip = keyPrefix + "store_zip" + keyBandPct = keyPrefix + "band_pct" + keyMarginFloorPct = keyPrefix + "margin_floor_pct" + + defaultFlowName = "FB Connect pricing flow" + defaultBandPct = "5" + defaultMarginPct = "15" + maxLabelLength = 120 + webhookIDPatternS = `^[A-Za-z0-9_-]{8,64}$` +) + +var ( + webhookIDPattern = regexp.MustCompile(webhookIDPatternS) + storeIDPattern = regexp.MustCompile(`^[0-9]{1,8}$`) + storeZipPattern = regexp.MustCompile(`^[0-9]{5}$`) + percentPattern = regexp.MustCompile(`^[0-9]{1,2}(\.[0-9]{1,2})?$`) +) + +// Settings is the answer of the settings routes. The webhook id is never in +// it: only whether one is saved. +type Settings struct { + ConnectURL string `json:"connect_url"` + WebhookIDSaved bool `json:"webhook_id_saved"` + FlowName string `json:"flow_name"` + StoreLabel string `json:"store_label"` + StoreID string `json:"store_id"` + StoreZip string `json:"store_zip"` + BandPct string `json:"band_pct"` + MarginFloorPct string `json:"margin_floor_pct"` + Configured bool `json:"configured"` +} + +// SettingsInput is the body of the settings write. A nil field keeps what is +// saved. WebhookID: nil keeps, "" clears. +type SettingsInput struct { + ConnectURL *string `json:"connect_url"` + WebhookID *string `json:"webhook_id"` + FlowName *string `json:"flow_name"` + StoreLabel *string `json:"store_label"` + StoreID *string `json:"store_id"` + StoreZip *string `json:"store_zip"` + BandPct *string `json:"band_pct"` + MarginFloorPct *string `json:"margin_floor_pct"` +} + +// ErrNotConfigured is the run's refusal when the address or the id is unset. +var ErrNotConfigured = &httpx.Error{Status: 409, Code: "not_configured", + Message: "Market check is off: an admin enters the FB Connect address and the flow's webhook id in Tech Admin"} + +func invalidField(field, message string) *httpx.Error { + return &httpx.Error{Status: 400, Code: httpx.CodeValidationFailed, Message: "the request is not valid", + Details: []httpx.FieldError{{Field: field, Message: message}}} +} + +// FieldOf names the first field of a validation refusal ("" for any other error). +func FieldOf(err error) string { + var he *httpx.Error + if errors.As(err, &he) && len(he.Details) > 0 { + return he.Details[0].Field + } + return "" +} + +func isLoopbackHost(h string) bool { + if strings.EqualFold(h, "localhost") { + return true + } + ip := net.ParseIP(h) + return ip != nil && ip.IsLoopback() +} + +func cleanLabel(field, s string) (string, error) { + s = strings.TrimSpace(s) + if len([]rune(s)) > maxLabelLength { + return "", invalidField(field, "must be at most 120 characters") + } + for _, r := range s { + if unicode.IsControl(r) { + return "", invalidField(field, "must not hold control characters") + } + } + return s, nil +} + +// parseConnectURL accepts https://host[:port] only (http for a loopback host) +// and returns it in canonical form with no trailing slash. +func parseConnectURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.Hostname() == "" { + return "", invalidField("connect_url", "must be an address like https://connect.example.com") + } + if u.User != nil { + return "", invalidField("connect_url", "must not hold a user name or password") + } + if u.RawQuery != "" || u.Fragment != "" || u.ForceQuery || (u.Path != "" && u.Path != "/") { + return "", invalidField("connect_url", "must be the address only: no path, query or fragment") + } + switch u.Scheme { + case "https": + case "http": + if !isLoopbackHost(u.Hostname()) { + return "", invalidField("connect_url", "must start with https:// (http:// is for a local host only)") + } + default: + return "", invalidField("connect_url", "must start with https://") + } + return u.Scheme + "://" + u.Host, nil +} + +// parsePercent reads a decimal percent string into hundredths of a percent. +func parsePercent(field, s string, max int64) (bp int64, canon string, err error) { + s = strings.TrimSpace(s) + if !percentPattern.MatchString(s) { + return 0, "", invalidField(field, fmt.Sprintf("must be a percent from 0 to %d, such as 5 or 12.5", max)) + } + f, _ := strconv.ParseFloat(s, 64) + bp = int64(f*100 + 0.5) + if bp > max*100 { + return 0, "", invalidField(field, fmt.Sprintf("must be a percent from 0 to %d", max)) + } + return bp, formatBP(bp), nil +} + +// formatBP writes hundredths of a percent as a minimal decimal string. +func formatBP(bp int64) string { + whole, frac := bp/100, bp%100 + switch { + case frac == 0: + return strconv.FormatInt(whole, 10) + case frac%10 == 0: + return fmt.Sprintf("%d.%d", whole, frac/10) + } + return fmt.Sprintf("%d.%02d", whole, frac) +} + +// load reads the saved settings (the id stays sealed; the caller opens it). +type loaded struct { + Settings + sealedID string +} + +func (s *Service) load(ctx context.Context) (loaded, error) { + get := func(k, def string) (string, error) { + v, ok, err := s.store.Read(ctx, k) + if err != nil { + return "", err + } + if !ok || v == "" { + return def, nil + } + return v, nil + } + var l loaded + var err error + if l.ConnectURL, err = get(keyConnectURL, ""); err != nil { + return l, err + } + if l.sealedID, err = get(keyWebhookID, ""); err != nil { + return l, err + } + if l.FlowName, err = get(keyFlowName, defaultFlowName); err != nil { + return l, err + } + if l.StoreLabel, err = get(keyStoreLabel, ""); err != nil { + return l, err + } + if l.StoreID, err = get(keyStoreID, ""); err != nil { + return l, err + } + if l.StoreZip, err = get(keyStoreZip, ""); err != nil { + return l, err + } + if l.BandPct, err = get(keyBandPct, defaultBandPct); err != nil { + return l, err + } + if l.MarginFloorPct, err = get(keyMarginFloorPct, defaultMarginPct); err != nil { + return l, err + } + l.WebhookIDSaved = l.sealedID != "" + l.Configured = l.WebhookIDSaved && l.ConnectURL != "" + return l, nil +} + +// Settings answers the saved settings, never the webhook id. +func (s *Service) Settings(ctx context.Context) (Settings, error) { + l, err := s.load(ctx) + return l.Settings, err +} + +// SaveSettings validates the whole input, then writes it in one transaction. +func (s *Service) SaveSettings(ctx context.Context, in SettingsInput) (Settings, error) { + writes := map[string]string{} + deletes := []string{} + var changed []string + setStr := func(key, field string, v *string, clean func(string) (string, error)) error { + if v == nil { + return nil + } + c, err := clean(*v) + if err != nil { + return err + } + changed = append(changed, field) + if c == "" { + deletes = append(deletes, key) + } else { + writes[key] = c + } + return nil + } + pattern := func(field string, re *regexp.Regexp, words string) func(string) (string, error) { + return func(s string) (string, error) { + s = strings.TrimSpace(s) + if s != "" && !re.MatchString(s) { + return "", invalidField(field, words) + } + return s, nil + } + } + label := func(field string) func(string) (string, error) { + return func(s string) (string, error) { return cleanLabel(field, s) } + } + if err := setStr(keyConnectURL, "connect_url", in.ConnectURL, parseConnectURL); err != nil { + return Settings{}, err + } + if err := setStr(keyFlowName, "flow_name", in.FlowName, label("flow_name")); err != nil { + return Settings{}, err + } + if err := setStr(keyStoreLabel, "store_label", in.StoreLabel, label("store_label")); err != nil { + return Settings{}, err + } + if err := setStr(keyStoreID, "store_id", in.StoreID, pattern("store_id", storeIDPattern, "must be up to 8 digits")); err != nil { + return Settings{}, err + } + if err := setStr(keyStoreZip, "store_zip", in.StoreZip, pattern("store_zip", storeZipPattern, "must be five digits")); err != nil { + return Settings{}, err + } + if err := setStr(keyBandPct, "band_pct", in.BandPct, func(v string) (string, error) { + _, c, err := parsePercent("band_pct", v, 50) + return c, err + }); err != nil { + return Settings{}, err + } + if err := setStr(keyMarginFloorPct, "margin_floor_pct", in.MarginFloorPct, func(v string) (string, error) { + _, c, err := parsePercent("margin_floor_pct", v, 90) + return c, err + }); err != nil { + return Settings{}, err + } + if in.WebhookID != nil { + id := strings.TrimSpace(*in.WebhookID) + changed = append(changed, "webhook_id") + if id == "" { + deletes = append(deletes, keyWebhookID) + } else { + if !webhookIDPattern.MatchString(id) { + return Settings{}, invalidField("webhook_id", "must be 8 to 64 letters, digits, dashes or underscores") + } + sealed, err := ai.SealValue(s.vault, id) + if err != nil { + if errors.Is(err, ai.ErrNoVault) { + return Settings{}, &httpx.Error{Status: 400, Code: httpx.CodeValidationFailed, + Message: "the webhook id cannot be stored: no vault key is configured (set PAYMENT_VAULT_KEY)", + Details: []httpx.FieldError{{Field: "webhook_id", Message: "no vault key is configured, so the id is not stored"}}} + } + return Settings{}, errors.New("the webhook id could not be sealed") + } + writes[keyWebhookID] = sealed + } + } + apply := func(ctx context.Context) error { + for k, v := range writes { + if err := s.store.Write(ctx, k, v); err != nil { + return err + } + } + for _, k := range deletes { + if err := s.store.Delete(ctx, k); err != nil { + return err + } + } + return nil + } + var err error + if s.tx != nil { + err = s.tx.RunInTx(ctx, apply) + } else { + err = apply(ctx) + } + if err != nil { + return Settings{}, err + } + if s.audit != nil && len(changed) > 0 { + // field names only: never a value, never the id + _ = s.audit.Record(ctx, "market_check.settings_saved", changed) + } + return s.Settings(ctx) +} From 50d0057b0c12be08fa299596b56216ebdc8afa69 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 08:34:39 -0700 Subject: [PATCH 03/17] marketcheck: routes, Postgres adapters, wiring, contract, walls and wire tests Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/api/ROUTES.txt | 3 + core/api/fragments/marketcheck.yaml | 173 +++++++++++++ core/api/openapi.yaml | 149 +++++++++++ core/internal/app/serve/serve.go | 11 + core/internal/marketcheck/handler.go | 100 +++++++ core/internal/marketcheck/postgres.go | 115 +++++++++ core/internal/marketcheck/settings.go | 22 +- core/internal/marketcheck/wire_test.go | 244 ++++++++++++++++++ core/pkg/middleware/keybranch_allow.go | 3 + core/pkg/middleware/machinekey.go | 7 +- core/pkg/middleware/machinekey_census_test.go | 2 + 11 files changed, 821 insertions(+), 8 deletions(-) create mode 100644 core/api/fragments/marketcheck.yaml create mode 100644 core/internal/marketcheck/handler.go create mode 100644 core/internal/marketcheck/postgres.go create mode 100644 core/internal/marketcheck/wire_test.go diff --git a/core/api/ROUTES.txt b/core/api/ROUTES.txt index d5619e27d..4c9bdb84f 100644 --- a/core/api/ROUTES.txt +++ b/core/api/ROUTES.txt @@ -79,6 +79,8 @@ POST /api/v1/admin/exposure-scan internal/pricing h.HandleAdminScan GET /api/v1/admin/keys internal/techadmin h.ListKeys POST /api/v1/admin/keys internal/techadmin h.CreateKey DELETE /api/v1/admin/keys/{id} internal/techadmin h.RevokeKey +GET /api/v1/admin/market-check/settings internal/marketcheck wrap(settingsGuard, h.getSettings) +PUT /api/v1/admin/market-check/settings internal/marketcheck wrap(settingsGuard, h.putSettings) GET /api/v1/admin/modules internal/staff h.ListModules PUT /api/v1/admin/modules/{id} internal/staff h.SetModuleEnabled DELETE /api/v1/admin/settings/ai internal/techadmin h.DeleteAISettings @@ -336,6 +338,7 @@ POST /api/v1/pricing/category-rules/bulk internal/pricing h.HandleBulkUpsertRule DELETE /api/v1/pricing/category-rules/{id} internal/pricing h.HandleDeleteCategoryRule PUT /api/v1/pricing/category-rules/{id} internal/pricing h.HandleUpdateCategoryRule GET /api/v1/pricing/category-rules/{id}/audit internal/pricing h.HandleGetRuleAudit +POST /api/v1/pricing/market-check internal/marketcheck wrap(runGuard, h.run) GET /api/v1/pricing/matrix internal/pricing h.HandleGetMatrix GET /api/v1/pricing/rebates/programs internal/pricing h.HandleListPrograms POST /api/v1/pricing/rebates/programs internal/pricing h.HandleCreateProgram diff --git a/core/api/fragments/marketcheck.yaml b/core/api/fragments/marketcheck.yaml new file mode 100644 index 000000000..0701f93ca --- /dev/null +++ b/core/api/fragments/marketcheck.yaml @@ -0,0 +1,173 @@ +# SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +# SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors +# +# Market check (FROM-PRINCIPAL item 112 c): our walk in prices against a live +# competitor store, read through an FB Connect pricing flow by its webhook. The +# webhook id is a secret: it is sealed at rest and never answered. The flow's +# request and answer are documented in docs/integrations/MARKET-CHECK-FLOW.md. +# Component schemas are prefixed MarketCheck. +paths: + /api/v1/admin/market-check/settings: + get: + operationId: marketCheckGetSettings + tags: [pricing] + summary: Show the Market check settings + description: >- + Admin or owner. The FB Connect address, whether a webhook id is saved + (never the id), the flow's name, the comparison store's label, id and + ZIP, the band percent and the margin floor percent. configured is true + when the address and the id are both saved. + responses: + "200": + description: The settings. + content: + application/json: + schema: + $ref: "#/components/schemas/MarketCheckSettings" + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "500": { $ref: "#/components/responses/WireInternalError" } + put: + operationId: marketCheckSaveSettings + tags: [pricing] + summary: Save the Market check settings + description: >- + Admin or owner. A field left out keeps what is saved. webhook_id absent + keeps the saved id, an empty string clears it; a new id is sealed with + the vault (refused in words when no vault key is configured). The + address is https://host[:port] with no user info, path, query or + fragment (http only for a loopback host). Answers the GET shape. + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MarketCheckSettingsRequest" + responses: + "200": + description: The settings after the save. + content: + application/json: + schema: + $ref: "#/components/schemas/MarketCheckSettings" + "400": { $ref: "#/components/responses/WireBadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "413": { $ref: "#/components/responses/PayloadTooLarge" } + "500": { $ref: "#/components/responses/WireInternalError" } + /api/v1/pricing/market-check: + post: + operationId: marketCheckRun + tags: [pricing] + summary: Check picked products against the live store + description: >- + Admin or owner. Runs the FB Connect pricing flow for one to twelve + distinct products in batches of three. Per product: our walk in price, + the competitor's matched title and price, the difference and position + recomputed from our own figures, and a suggested price that keeps the + margin floor. A batch that fails marks only its own items error. 409 + not_configured when the address or the webhook id is not saved. Cost + is never in the answer and is never sent to the flow. + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MarketCheckRunRequest" + responses: + "200": + description: One row per picked product, in request order. + content: + application/json: + schema: + $ref: "#/components/schemas/MarketCheckResult" + "400": { $ref: "#/components/responses/WireBadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/WireConflict" } + "413": { $ref: "#/components/responses/PayloadTooLarge" } + "500": { $ref: "#/components/responses/WireInternalError" } +components: + schemas: + MarketCheckSettings: + type: object + required: [connect_url, webhook_id_saved, flow_name, store_label, store_id, store_zip, band_pct, margin_floor_pct, configured] + properties: + connect_url: {type: string} + webhook_id_saved: {type: boolean} + flow_name: {type: string} + store_label: {type: string} + store_id: {type: string} + store_zip: {type: string} + band_pct: {type: string, description: A decimal percent from 0 to 50.} + margin_floor_pct: {type: string, description: A decimal percent from 0 to 90.} + configured: {type: boolean} + MarketCheckSettingsRequest: + type: object + additionalProperties: false + properties: + connect_url: {type: string} + webhook_id: {type: string, description: Write only. Absent keeps the saved id; an empty string clears it.} + flow_name: {type: string} + store_label: {type: string} + store_id: {type: string} + store_zip: {type: string} + band_pct: {type: string} + margin_floor_pct: {type: string} + MarketCheckRunRequest: + type: object + additionalProperties: false + required: [product_ids] + properties: + product_ids: + type: array + minItems: 1 + maxItems: 12 + items: {type: string, format: uuid} + MarketCheckCompetitor: + type: object + required: [title, price_cents, listed_price_cents, link, confidence, unit_note] + properties: + title: {type: string} + price_cents: {type: integer, format: int64} + listed_price_cents: {type: integer, format: int64} + link: {type: string, description: Kept only when it starts with https://www.homedepot.com/, else empty.} + confidence: {type: integer, minimum: 0, maximum: 100} + unit_note: {type: string} + MarketCheckRow: + type: object + required: [product_id, sku, description, uom, our_price_cents, cost_known, status, competitor, diff_pct, position, suggested_price_cents, reason, note] + properties: + product_id: {type: string, format: uuid} + sku: {type: string} + description: {type: string} + uom: {type: string} + our_price_cents: {type: integer, format: int64} + cost_known: {type: boolean} + status: {type: string, enum: [matched, unmatched, error]} + competitor: + nullable: true + allOf: + - $ref: "#/components/schemas/MarketCheckCompetitor" + diff_pct: {type: number, nullable: true} + position: {type: string, enum: ["", above, below, in_line]} + suggested_price_cents: {type: integer, format: int64, nullable: true} + reason: {type: string, enum: ["", lower_to_band, raise_to_band, held_at_floor, keep, no_cost]} + note: {type: string} + MarketCheckResult: + type: object + required: [flow_name, store_label, store_seen, band_pct, margin_floor_pct, rows] + properties: + flow_name: {type: string} + store_label: {type: string} + store_seen: {type: string, nullable: true} + band_pct: {type: string} + margin_floor_pct: {type: string} + rows: + type: array + items: + $ref: "#/components/schemas/MarketCheckRow" diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index 75f8b5054..bef7ec06f 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -8212,6 +8212,77 @@ paths: $ref: "#/components/responses/UnprocessableEntity" "500": $ref: "#/components/responses/InternalError" + /api/v1/admin/market-check/settings: + get: + operationId: marketCheckGetSettings + tags: [pricing] + summary: Show the Market check settings + description: >- + Admin or owner. The FB Connect address, whether a webhook id is saved (never the id), the flow's name, the comparison store's label, id and ZIP, the band percent and the margin floor percent. configured is true when the address and the id are both saved. + responses: + "200": + description: The settings. + content: + application/json: + schema: + $ref: "#/components/schemas/MarketCheckSettings" + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + "500": {$ref: "#/components/responses/WireInternalError"} + put: + operationId: marketCheckSaveSettings + tags: [pricing] + summary: Save the Market check settings + description: >- + Admin or owner. A field left out keeps what is saved. webhook_id absent keeps the saved id, an empty string clears it; a new id is sealed with the vault (refused in words when no vault key is configured). The address is https://host[:port] with no user info, path, query or fragment (http only for a loopback host). Answers the GET shape. + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MarketCheckSettingsRequest" + responses: + "200": + description: The settings after the save. + content: + application/json: + schema: + $ref: "#/components/schemas/MarketCheckSettings" + "400": {$ref: "#/components/responses/WireBadRequest"} + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + "413": {$ref: "#/components/responses/PayloadTooLarge"} + "500": {$ref: "#/components/responses/WireInternalError"} + /api/v1/pricing/market-check: + post: + operationId: marketCheckRun + tags: [pricing] + summary: Check picked products against the live store + description: >- + Admin or owner. Runs the FB Connect pricing flow for one to twelve distinct products in batches of three. Per product: our walk in price, the competitor's matched title and price, the difference and position recomputed from our own figures, and a suggested price that keeps the margin floor. A batch that fails marks only its own items error. 409 not_configured when the address or the webhook id is not saved. Cost is never in the answer and is never sent to the flow. + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MarketCheckRunRequest" + responses: + "200": + description: One row per picked product, in request order. + content: + application/json: + schema: + $ref: "#/components/schemas/MarketCheckResult" + "400": {$ref: "#/components/responses/WireBadRequest"} + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + "409": {$ref: "#/components/responses/WireConflict"} + "413": {$ref: "#/components/responses/PayloadTooLarge"} + "500": {$ref: "#/components/responses/WireInternalError"} /api/v1/matching/config: get: operationId: matchingConfigGet @@ -21848,6 +21919,84 @@ components: type: array items: type: string + MarketCheckSettings: + type: object + required: [connect_url, webhook_id_saved, flow_name, store_label, store_id, store_zip, band_pct, margin_floor_pct, configured] + properties: + connect_url: {type: string} + webhook_id_saved: {type: boolean} + flow_name: {type: string} + store_label: {type: string} + store_id: {type: string} + store_zip: {type: string} + band_pct: {type: string, description: A decimal percent from 0 to 50.} + margin_floor_pct: {type: string, description: A decimal percent from 0 to 90.} + configured: {type: boolean} + MarketCheckSettingsRequest: + type: object + additionalProperties: false + properties: + connect_url: {type: string} + webhook_id: {type: string, description: Write only. Absent keeps the saved id; an empty string clears it.} + flow_name: {type: string} + store_label: {type: string} + store_id: {type: string} + store_zip: {type: string} + band_pct: {type: string} + margin_floor_pct: {type: string} + MarketCheckRunRequest: + type: object + additionalProperties: false + required: [product_ids] + properties: + product_ids: + type: array + minItems: 1 + maxItems: 12 + items: {type: string, format: uuid} + MarketCheckCompetitor: + type: object + required: [title, price_cents, listed_price_cents, link, confidence, unit_note] + properties: + title: {type: string} + price_cents: {type: integer, format: int64} + listed_price_cents: {type: integer, format: int64} + link: {type: string, description: 'Kept only when it starts with https://www.homedepot.com/', else empty.: ''} + confidence: {type: integer, minimum: 0, maximum: 100} + unit_note: {type: string} + MarketCheckRow: + type: object + required: [product_id, sku, description, uom, our_price_cents, cost_known, status, competitor, diff_pct, position, suggested_price_cents, reason, note] + properties: + product_id: {type: string, format: uuid} + sku: {type: string} + description: {type: string} + uom: {type: string} + our_price_cents: {type: integer, format: int64} + cost_known: {type: boolean} + status: {type: string, enum: [matched, unmatched, error]} + competitor: + nullable: true + allOf: + - $ref: "#/components/schemas/MarketCheckCompetitor" + diff_pct: {type: number, nullable: true} + position: {type: string, enum: ["", above, below, in_line]} + suggested_price_cents: {type: integer, format: int64, nullable: true} + reason: {type: string, enum: ["", lower_to_band, raise_to_band, held_at_floor, keep, no_cost]} + note: {type: string} + MarketCheckResult: + type: object + required: [flow_name, store_label, store_seen, band_pct, margin_floor_pct, rows] + properties: + flow_name: {type: string} + store_label: {type: string} + store_seen: {type: string, nullable: true} + band_pct: {type: string} + margin_floor_pct: {type: string} + rows: + type: array + items: + $ref: "#/components/schemas/MarketCheckRow" MatchingStatus: type: string enum: [PENDING, MATCHED, PARTIAL, EXCEPTION] diff --git a/core/internal/app/serve/serve.go b/core/internal/app/serve/serve.go index 2d2f3615c..660743671 100644 --- a/core/internal/app/serve/serve.go +++ b/core/internal/app/serve/serve.go @@ -58,6 +58,7 @@ import ( "github.com/gablelbm/gable/internal/pim" "github.com/gablelbm/gable/internal/portal" "github.com/gablelbm/gable/internal/pos" + "github.com/gablelbm/gable/internal/marketcheck" "github.com/gablelbm/gable/internal/pricing" "github.com/gablelbm/gable/internal/product" "github.com/gablelbm/gable/internal/project" @@ -401,6 +402,16 @@ func Run() { pricingHandler := pricing.NewHandler(pricingSvc, customerSvc, productSvc) pricingHandler.RegisterRoutes(mux, pricingRulesCustomerWall(wall.keyWall, db, "admin", "owner")) + // Market check: our prices against a live store through an FB Connect + // pricing flow (item 112 c). Settings are Tech Admin's; the run sits behind + // the pricing rules' walls. + marketCheckSvc := marketcheck.NewService(marketcheck.NewPostgresStore(db), marketcheck.NewPostgresCatalog(db)). + WithVault(aiVault).WithTxRunner(db).WithLogger(logger). + WithAudit(marketcheck.AuditRecorder{L: auditLog}) + marketcheck.NewHandler(marketCheckSvc).RegisterRoutes(mux, + middleware.RequireRole("admin", "owner"), + pricingRulesCustomerWall(wall.keyWall, db, "admin", "owner")) + // Category Pricing Engine (feature-flagged) if strings.EqualFold(os.Getenv("CATEGORY_PRICING_ENABLED"), "true") { catPricingRepo := pricing.NewCategoryRepository(db) diff --git a/core/internal/marketcheck/handler.go b/core/internal/marketcheck/handler.go new file mode 100644 index 000000000..882f73c7f --- /dev/null +++ b/core/internal/marketcheck/handler.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package marketcheck + +import ( + "encoding/json" + "net/http" + + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/google/uuid" +) + +// Handler serves the settings and the run. +type Handler struct{ svc *Service } + +// NewHandler builds the handler. +func NewHandler(s *Service) *Handler { return &Handler{svc: s} } + +// RegisterRoutes mounts the three routes. settingsGuard walls the Tech Admin +// settings; runGuard walls the run (the pricing rules' walls). +func (h *Handler) RegisterRoutes(mux *http.ServeMux, settingsGuard, runGuard func(http.Handler) http.Handler) { + wrap := func(g func(http.Handler) http.Handler, f http.HandlerFunc) http.Handler { + if g == nil { + return f + } + return g(f) + } + mux.Handle("GET /api/v1/admin/market-check/settings", wrap(settingsGuard, h.getSettings)) + mux.Handle("PUT /api/v1/admin/market-check/settings", wrap(settingsGuard, h.putSettings)) + mux.Handle("POST /api/v1/pricing/market-check", wrap(runGuard, h.run)) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func (h *Handler) getSettings(w http.ResponseWriter, r *http.Request) { + if _, err := httpx.StrictQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } + out, err := h.svc.Settings(r.Context()) + if err != nil { + httpx.WriteError(w, r, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +func (h *Handler) putSettings(w http.ResponseWriter, r *http.Request) { + if _, err := httpx.StrictQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } + var in SettingsInput + if err := httpx.DecodeJSON(r, &in); err != nil { + httpx.WriteError(w, r, err) + return + } + out, err := h.svc.SaveSettings(r.Context(), in) + if err != nil { + httpx.WriteError(w, r, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +type runRequest struct { + ProductIDs []string `json:"product_ids"` +} + +func (h *Handler) run(w http.ResponseWriter, r *http.Request) { + if _, err := httpx.StrictQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } + var in runRequest + if err := httpx.DecodeJSON(r, &in); err != nil { + httpx.WriteError(w, r, err) + return + } + ids := make([]uuid.UUID, 0, len(in.ProductIDs)) + for _, s := range in.ProductIDs { + id, err := uuid.Parse(s) + if err != nil { + httpx.WriteError(w, r, invalidField("product_ids", "each entry must be a product id")) + return + } + ids = append(ids, id) + } + out, err := h.svc.Run(r.Context(), ids) + if err != nil { + httpx.WriteError(w, r, err) + return + } + writeJSON(w, http.StatusOK, out) +} diff --git a/core/internal/marketcheck/postgres.go b/core/internal/marketcheck/postgres.go new file mode 100644 index 000000000..18e9d5324 --- /dev/null +++ b/core/internal/marketcheck/postgres.go @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package marketcheck + +import ( + "context" + "errors" + "strings" + + "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/database" + "github.com/gablelbm/gable/pkg/middleware" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// PostgresStore keeps the settings in system_settings. Every statement goes +// through GetExecutor: inside a transaction it is the transaction. +type PostgresStore struct{ db *database.DB } + +// NewPostgresStore builds the settings store. +func NewPostgresStore(db *database.DB) *PostgresStore { return &PostgresStore{db: db} } + +// Read returns one setting. +func (p *PostgresStore) Read(ctx context.Context, key string) (string, bool, error) { + var v string + err := p.db.GetExecutor(ctx).QueryRow(ctx, `SELECT value FROM system_settings WHERE key = $1`, key).Scan(&v) + if errors.Is(err, pgx.ErrNoRows) { + return "", false, nil + } + return v, err == nil, err +} + +// Write upserts one setting. +func (p *PostgresStore) Write(ctx context.Context, key, value string) error { + _, err := p.db.GetExecutor(ctx).Exec(ctx, ` + INSERT INTO system_settings (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`, key, value) + return err +} + +// Delete removes one setting. +func (p *PostgresStore) Delete(ctx context.Context, key string) error { + _, err := p.db.GetExecutor(ctx).Exec(ctx, `DELETE FROM system_settings WHERE key = $1`, key) + return err +} + +// PostgresCatalog reads the yard and the products. Our price is the product's +// base price in cents, exactly the figure the counter's search answers as +// unit_price_cents (pos SearchProducts), and our cost its average unit cost. +type PostgresCatalog struct{ db *database.DB } + +// NewPostgresCatalog builds the catalogue reader. +func NewPostgresCatalog(db *database.DB) *PostgresCatalog { return &PostgresCatalog{db: db} } + +// Yard answers the caller's yard: the branch in context, else the dealer's +// default branch, else the first active branch. +func (c *PostgresCatalog) Yard(ctx context.Context) (Yard, error) { + var name, city, state string + err := c.db.GetExecutor(ctx).QueryRow(ctx, ` + SELECT COALESCE(l.name, ''), COALESCE(l.city, ''), COALESCE(l.state, '') + FROM locations l + WHERE l.type = 'BRANCH' AND l.active + ORDER BY COALESCE(l.id = $1::uuid, FALSE) DESC, + COALESCE(l.id::text = (SELECT value FROM system_settings WHERE key = 'default_branch_id'), FALSE) DESC, + l.name, l.id + LIMIT 1`, middleware.BranchIDForQuery(ctx)).Scan(&name, &city, &state) + if errors.Is(err, pgx.ErrNoRows) { + return Yard{}, nil + } + if err != nil { + return Yard{}, err + } + town := strings.TrimSpace(city) + if st := strings.TrimSpace(state); st != "" { + if town != "" { + town += ", " + } + town += st + } + return Yard{Name: strings.TrimSpace(name), Town: town}, nil +} + +// Products reads the picked products. +func (c *PostgresCatalog) Products(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]Product, error) { + rows, err := c.db.GetExecutor(ctx).Query(ctx, ` + SELECT id, COALESCE(sku, ''), COALESCE(description, ''), COALESCE(category, ''), + COALESCE(uom_primary::text, 'EA'), + COALESCE(ROUND(base_price * 100)::bigint, 0), + COALESCE(ROUND(average_unit_cost * 100)::bigint, 0) + FROM products WHERE id = ANY($1)`, ids) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[uuid.UUID]Product{} + for rows.Next() { + var p Product + if err := rows.Scan(&p.ID, &p.SKU, &p.Description, &p.Category, &p.UOM, &p.PriceCents, &p.CostCents); err != nil { + return nil, err + } + out[p.ID] = p + } + return out, rows.Err() +} + +// AuditRecorder writes a settings change to the platform audit log. +type AuditRecorder struct{ L *audit.Logger } + +// Record logs the action with the changed field names only. +func (a AuditRecorder) Record(ctx context.Context, action string, fields []string) error { + return a.L.Log(ctx, audit.Entry{Action: action, EntityType: "market_check_settings", + EntityID: uuid.Nil, Changes: map[string]interface{}{"fields": fields}}) +} diff --git a/core/internal/marketcheck/settings.go b/core/internal/marketcheck/settings.go index fd4288583..d94ae8272 100644 --- a/core/internal/marketcheck/settings.go +++ b/core/internal/marketcheck/settings.go @@ -10,6 +10,7 @@ import ( "net" "net/url" "regexp" + "sort" "strconv" "strings" "unicode" @@ -301,13 +302,22 @@ func (s *Service) SaveSettings(ctx context.Context, in SettingsInput) (Settings, } } apply := func(ctx context.Context) error { - for k, v := range writes { - if err := s.store.Write(ctx, k, v); err != nil { - return err - } + // One fixed key order, so two saves at once lock rows in the same + // order and never deadlock. + keys := make([]string, 0, len(writes)+len(deletes)) + for k := range writes { + keys = append(keys, k) } - for _, k := range deletes { - if err := s.store.Delete(ctx, k); err != nil { + keys = append(keys, deletes...) + sort.Strings(keys) + for _, k := range keys { + var err error + if v, ok := writes[k]; ok { + err = s.store.Write(ctx, k, v) + } else { + err = s.store.Delete(ctx, k) + } + if err != nil { return err } } diff --git a/core/internal/marketcheck/wire_test.go b/core/internal/marketcheck/wire_test.go new file mode 100644 index 000000000..80600d1b6 --- /dev/null +++ b/core/internal/marketcheck/wire_test.go @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package marketcheck_test + +// The Market check on the wire, against a real Postgres: the real handler on a +// real mux behind the role guards, the real settings store and the real vault. +// The flow is a stub of FB Connect on a loopback port. + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gablelbm/gable/internal/marketcheck" + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/database" + "github.com/gablelbm/gable/pkg/middleware" + "github.com/google/uuid" +) + +// A made up vault key: 32 bytes of an obvious repeating placeholder. +var testVaultKey = strings.Repeat("ab", 32) + +type wireFixture struct { + t *testing.T + db *database.DB + srv *httptest.Server + svc *marketcheck.Service + logs *logCapture +} + +func claimsAs(role string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if role != "" { + c := &middleware.UserClaims{Role: role} + r = r.WithContext(context.WithValue(r.Context(), middleware.UserContextKey, c)) + } + next.ServeHTTP(w, r) + }) +} + +func cleanMarketRows(db *database.DB) { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM system_settings WHERE key LIKE 'market_check.%'`) +} + +func newWire(t *testing.T, vaultKey string, maxConns int32) *wireFixture { + t.Helper() + db := testutil.RequireDBMaxConns(t, maxConns) + vault, err := payment.NewVault(vaultKey) + if err != nil { + t.Fatal(err) + } + lg, cap := newLogger() + svc := marketcheck.NewService(marketcheck.NewPostgresStore(db), marketcheck.NewPostgresCatalog(db)). + WithVault(vault).WithTxRunner(db).WithLogger(lg).WithBatchTimeout(2 * time.Second) + mux := http.NewServeMux() + guard := middleware.RequireRole("admin", "owner") + marketcheck.NewHandler(svc).RegisterRoutes(mux, guard, guard) + f := &wireFixture{t: t, db: db, svc: svc, logs: cap} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claimsAs(r.Header.Get("X-Test-Role"), mux).ServeHTTP(w, r) + })) + cleanMarketRows(db) + t.Cleanup(func() { f.srv.Close(); cleanMarketRows(db) }) + return f +} + +func (f *wireFixture) do(role, method, path string, body any) (int, string) { + f.t.Helper() + var rd io.Reader + if body != nil { + b, _ := json.Marshal(body) + rd = bytes.NewReader(b) + } + req, _ := http.NewRequest(method, f.srv.URL+path, rd) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Test-Role", role) + resp, err := http.DefaultClient.Do(req) + if err != nil { + f.t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(b) +} + +func (f *wireFixture) seedProduct(sku string, price, cost float64) uuid.UUID { + f.t.Helper() + id := uuid.New() + if _, err := f.db.Pool.Exec(context.Background(), `INSERT INTO products (id, sku, description, uom_primary, base_price, average_unit_cost, category) + VALUES ($1, $2, 'market check test product', 'PCS', $3, $4, 'Lumber')`, id, sku, price, cost); err != nil { + f.t.Fatal(err) + } + f.t.Cleanup(func() { _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM products WHERE id = $1`, id) }) + return id +} + +func TestWire_SettingsSealedAtRestAndNeverAnswered(t *testing.T) { + f := newWire(t, testVaultKey, 10) + code, body := f.do("admin", "PUT", "/api/v1/admin/market-check/settings", map[string]any{ + "connect_url": "https://connect.example.test", "webhook_id": testHook, "band_pct": "6", "flow_name": "Pricing flow"}) + if code != 200 || strings.Contains(body, testHook) || !strings.Contains(body, `"configured":true`) { + t.Fatalf("put: %d %s", code, body) + } + code, body = f.do("owner", "GET", "/api/v1/admin/market-check/settings", nil) + if code != 200 || strings.Contains(body, testHook) || !strings.Contains(body, `"webhook_id_saved":true`) || + !strings.Contains(body, `"band_pct":"6"`) { + t.Fatalf("get: %d %s", code, body) + } + var stored string + if err := f.db.Pool.QueryRow(context.Background(), `SELECT value FROM system_settings WHERE key = 'market_check.webhook_id'`).Scan(&stored); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(stored, "enc:v1:") || strings.Contains(stored, testHook) { + t.Fatalf("stored value: %q", stored) + } + var plain int + _ = f.db.Pool.QueryRow(context.Background(), `SELECT COUNT(*) FROM system_settings WHERE value LIKE '%' || $1 || '%'`, testHook).Scan(&plain) + if plain != 0 { + t.Fatalf("the id is stored in plain text in %d rows", plain) + } + // unknown fields are refused + if code, _ = f.do("admin", "PUT", "/api/v1/admin/market-check/settings", map[string]any{"cost": 1}); code != 400 { + t.Fatalf("unknown field: %d", code) + } +} + +func TestWire_NoVaultKeyRefusesInWords(t *testing.T) { + f := newWire(t, "", 10) + code, body := f.do("admin", "PUT", "/api/v1/admin/market-check/settings", map[string]any{ + "connect_url": "https://connect.example.test", "webhook_id": testHook}) + if code != 400 || !strings.Contains(body, "vault") || strings.Contains(body, testHook) { + t.Fatalf("%d %s", code, body) + } + var n int + _ = f.db.Pool.QueryRow(context.Background(), `SELECT COUNT(*) FROM system_settings WHERE key LIKE 'market_check.%'`).Scan(&n) + if n != 0 { + t.Fatalf("a refused save wrote %d rows", n) + } +} + +func TestWire_RolesAreWalled(t *testing.T) { + f := newWire(t, testVaultKey, 10) + for _, role := range []string{"cashier", "sales", "driver"} { + for _, rt := range []struct{ m, p string }{ + {"GET", "/api/v1/admin/market-check/settings"}, + {"PUT", "/api/v1/admin/market-check/settings"}, + {"POST", "/api/v1/pricing/market-check"}, + } { + code, _ := f.do(role, rt.m, rt.p, map[string]any{"product_ids": []string{uuid.NewString()}}) + if code != http.StatusForbidden { + t.Fatalf("%s %s %s: %d", role, rt.m, rt.p, code) + } + } + } +} + +func TestWire_RunEndToEnd(t *testing.T) { + f := newWire(t, testVaultKey, 10) + p := f.seedProduct("MC_TEST_SKU_1", 5.42, 3.00) + var calls int + var mu sync.Mutex + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + calls++ + mu.Unlock() + if r.URL.Path != "/api/v1/webhooks/"+testHook+"/sync" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"store":{"id":"1","name":"Spokane"},"rows":[{"sku":"MC_TEST_SKU_1","status":"matched","match":{"title":"Stud","theirPrice":4.00,"listedPrice":4.00,"link":"https://www.homedepot.com/p/1","confidence":88,"normNote":""}}]}`)) + })) + defer stub.Close() + // not configured first + if code, body := f.do("admin", "POST", "/api/v1/pricing/market-check", map[string]any{"product_ids": []string{p.String()}}); code != 409 || !strings.Contains(body, "not_configured") { + t.Fatalf("not configured: %d %s", code, body) + } + if code, body := f.do("admin", "PUT", "/api/v1/admin/market-check/settings", map[string]any{ + "connect_url": stub.URL, "webhook_id": testHook}); code != 200 { + t.Fatalf("save: %d %s", code, body) + } + code, body := f.do("admin", "POST", "/api/v1/pricing/market-check", map[string]any{"product_ids": []string{p.String()}}) + if code != 200 { + t.Fatalf("run: %d %s", code, body) + } + var res marketcheck.Result + if err := json.Unmarshal([]byte(body), &res); err != nil { + t.Fatal(err) + } + r := res.Rows[0] + if r.Status != "matched" || r.OurPriceCents != 542 || r.Position != "above" || r.DiffPct == nil || *r.DiffPct != 35.5 || + r.SuggestedPriceCents == nil || *r.SuggestedPriceCents != 420 { + t.Fatalf("row: %+v", r) + } + if strings.Contains(body, testHook) || strings.Contains(body, "average_unit_cost") || strings.Contains(f.logs.String(), testHook) { + t.Fatalf("a leak:\n%s\n%s", body, f.logs.String()) + } + if code, _ = f.do("admin", "POST", "/api/v1/pricing/market-check", map[string]any{"product_ids": []string{}}); code != 400 { + t.Fatalf("empty pick: %d", code) + } +} + +// The settings write opens a transaction: at pool size 4 with three +// contenders every save must finish (a pool read inside the transaction would +// deadlock here). +func TestWire_SettingsSaveConcurrencyPoolOfFour(t *testing.T) { + f := newWire(t, testVaultKey, 4) + var wg sync.WaitGroup + errs := make(chan error, 3*8) + for c := 0; c < 3; c++ { + wg.Add(1) + go func(c int) { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + for i := 0; i < 8; i++ { + _, err := f.svc.SaveSettings(ctx, marketcheck.SettingsInput{ + ConnectURL: ptr("https://connect.example.test"), WebhookID: ptr(testHook), + FlowName: ptr("flow"), StoreZip: ptr("99201")}) + errs <- err + } + }(c) + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("a contender failed: %v", err) + } + } + got, err := f.svc.Settings(context.Background()) + if err != nil || !got.Configured { + t.Fatalf("%+v %v", got, err) + } +} diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index d759290df..2b2a466bf 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -92,6 +92,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/ar/customers/{id}/statement/email": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_DocumentRoutes"}, "GET /api/v1/documents/print/statement/{id}": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_DocumentRoutes"}, "GET /api/v1/branches/{id}/users": {KeyBranchClassConfined, "NamedBranch(id); TestKeyBranchPin_LocationReads"}, + "POST /api/v1/pricing/market-check": {KeyBranchClassWalled, "the branch middleware holds the pin: the yard the flow is told and the caller's role come from branch context; the answer carries no cost; TestMarketCheckWire"}, "GET /api/v1/pricing/calculate": {KeyBranchClassConfined, "CustomerBranch(query customer_id, and job_id to projects.customer_id); TestKeyBranchPin_CustomerConfinedReads"}, "GET /api/v1/pricing/category-rules": {KeyBranchClassConfined, "CustomerBranch(query customer_id, required); TestKeyBranchPin_CustomerConfinedReads"}, "GET /api/v1/pricing/category-rules/{id}/audit": {KeyBranchClassConfined, "CustomerRowBranch, tier rows refused; TestKeyBranchPin_CategoryTierRefusals"}, @@ -217,6 +218,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "PUT /api/v1/admin/modules/{id}": {KeyBranchClassRefused, "a module kill switch is a dealer wide write; the read stays a stated limit"}, "PUT /api/v1/admin/settings/ai": {KeyBranchClassRefused, "a dealer wide configuration write (the provider key and base URL); the read stays a stated limit"}, "POST /api/v1/admin/settings/ai/test": {KeyBranchClassRefused, "a dealer wide configuration action that spends the provider key; no branch binds it"}, + "PUT /api/v1/admin/market-check/settings": {KeyBranchClassRefused, "a dealer wide configuration write (the FB Connect address and the webhook id); the read stays a stated limit"}, "PUT /api/v1/admin/settings/routing": {KeyBranchClassRefused, "a dealer wide configuration write (the provider key and base URL); the read stays a stated limit"}, "PUT /api/v1/admin/staff/{id}": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, "PUT /api/v1/charge-codes/{id}": {KeyBranchClassRefused, "charge codes carry a revenue account and no branch column; the reads stay stated limits"}, @@ -236,6 +238,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "PUT /api/v1/units/{code}": {KeyBranchClassRefused, "the unit catalogue is dealer wide; the reads stay stated limits"}, "GET /api/v1/admin/modules": {KeyBranchClassStatedLimit, "module kill switches change dealer wide behaviour"}, "GET /api/v1/admin/settings/ai": {KeyBranchClassStatedLimit, "dealer wide configuration"}, + "GET /api/v1/admin/market-check/settings": {KeyBranchClassStatedLimit, "dealer wide configuration; the webhook id is never answered"}, "GET /api/v1/admin/settings/routing": {KeyBranchClassStatedLimit, "dealer wide configuration"}, "GET /api/v1/apps": {KeyBranchClassStatedLimit, "app enablement switches change dealer wide behaviour"}, "GET /api/v1/branches": {KeyBranchClassStatedLimit, "branch directory reference data (the PR 39 decision)"}, diff --git a/core/pkg/middleware/machinekey.go b/core/pkg/middleware/machinekey.go index 3a886beaa..4a60de33a 100644 --- a/core/pkg/middleware/machinekey.go +++ b/core/pkg/middleware/machinekey.go @@ -185,8 +185,11 @@ func RequiredScope(module string, method string) string { // read/write split is the module level rule, not the area's. var adminAreaScopes = map[string]string{ "settings": "admin:settings", - "staff": "admin:staff", - "modules": "admin:modules", + // The Market check's address and sealed webhook id are settings: the + // same authority that saves the AI key saves them. + "market-check": "admin:settings", + "staff": "admin:staff", + "modules": "admin:modules", } // writeScopeOverrides replaces a module's write scope name with a finer one diff --git a/core/pkg/middleware/machinekey_census_test.go b/core/pkg/middleware/machinekey_census_test.go index 18aeb6da0..6f0e0cdba 100644 --- a/core/pkg/middleware/machinekey_census_test.go +++ b/core/pkg/middleware/machinekey_census_test.go @@ -236,6 +236,8 @@ var adminUsersRouteExpectations = map[string]string{ "GET /api/v1/admin/keys": scopeUserOnly, "POST /api/v1/admin/keys": scopeUserOnly, "DELETE /api/v1/admin/keys/{id}": scopeUserOnly, + "GET /api/v1/admin/market-check/settings": "admin:settings", + "PUT /api/v1/admin/market-check/settings": "admin:settings", "GET /api/v1/admin/modules": "admin:modules", "PUT /api/v1/admin/modules/{id}": "admin:modules", "DELETE /api/v1/admin/settings/ai": "admin:settings", From 77d7afb033e9d70d81897e5f67dd2b3486937f94 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 08:34:56 -0700 Subject: [PATCH 04/17] docs: the Market check flow contract, the contract change row and the walk Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- docs/integrations/MARKET-CHECK-FLOW.md | 69 ++++++++++++++++++++++++++ docs/parity/WALKS.md | 18 +++++++ docs/refactor/CONTRACT-CHANGES.md | 1 + 3 files changed, 88 insertions(+) create mode 100644 docs/integrations/MARKET-CHECK-FLOW.md diff --git a/docs/integrations/MARKET-CHECK-FLOW.md b/docs/integrations/MARKET-CHECK-FLOW.md new file mode 100644 index 000000000..d5990ad66 --- /dev/null +++ b/docs/integrations/MARKET-CHECK-FLOW.md @@ -0,0 +1,69 @@ + + +# Market check: the pricing flow's request and answer + +The Market check screen (Pricing, Market check) asks an FB Connect pricing flow what a competitor's store charges +for products the dealer sells. This page is the contract between Gable and that flow. It holds no address and no +webhook id: both are dealer settings in Tech Admin, kept sealed at rest, and anyone holding the id can run the flow. + +## The call + +Gable's server posts, once per batch of up to three items (at most four batches at once), to + + POST {connect address}/api/v1/webhooks/{webhook id}/sync + Content-Type: application/json + +Each batch has its own timeout. Redirects are not followed; a 3xx is a failed batch. The answer is read up to 1 MB. + +## The request + +```json +{ + "source": "gable", + "yard": {"name": "Kelowna Main Yard", "town": "Kelowna, BC"}, + "store": {"id": "", "zip": "", "label": ""}, + "bandPercent": 5, + "items": [ + {"sku": "DL_SPF_2X4_L08_S", "name": "2x4x8' SPF #2&BTR KD", "category": "Lumber", "unit": "PCS", "price": 5.42} + ] +} +``` + +`price` is the yard's walk in price in dollars, a JSON number with two decimals. Cost is never sent. + +## The answer + +```json +{ + "store": {"id": "1234", "name": "Spokane Store"}, + "rows": [ + { + "sku": "DL_SPF_2X4_L08_S", + "status": "matched", + "match": {"title": "...", "theirPrice": 4.00, "listedPrice": 4.00, "link": "https://www.homedepot.com/...", "confidence": 90, "normNote": "Sold as a pack of 10; price divided by 10"}, + "note": "" + } + ] +} +``` + +`store` may be null. `status` is `matched`, `unmatched` or `error`; `match` is absent unless matched. Other fields +are ignored. `theirPrice` is the per unit price after the flow's own unit normalisation; `listedPrice` is the price +as listed. + +## What Gable does with the rows (they are untrusted) + +- Rows are matched to the items sent by `sku`. A SKU Gable did not send is ignored. An item with no row is an error + row ("The flow did not return this item"). +- Title and note are cut to 200 and 300 characters with control characters removed. +- A price must be finite, above zero and below 100000 dollars, or the row is an error row. +- Confidence is clamped to 0 to 100. +- A link is kept only when it starts with `https://www.homedepot.com/`; any other is dropped. +- The difference and the position are recomputed from Gable's own price and the competitor's `theirPrice`; the flow's + own difference, if it sends one, is ignored. +- The suggested price keeps the dealer's margin floor and is rounded in whole cents: the floor rounds up, a band + target rounds down. +- A failed batch (network, timeout, a non 200 answer, bad JSON, too large) marks only its own items as errors. diff --git a/docs/parity/WALKS.md b/docs/parity/WALKS.md index 728407927..a7024e770 100644 --- a/docs/parity/WALKS.md +++ b/docs/parity/WALKS.md @@ -255,4 +255,22 @@ need. Save a PDF with `curl -s -o form.pdf` and read it with `pdftotext fo (`GET /api/v1/documents/print/purchase-order/`): the quote arrives in the catcher; the purchase order shows the vendor, the placing yard's address and its lines. +## Walk: Market check, our prices against a live store through FB Connect + +Needs the demo seed, a vault key (`PAYMENT_VAULT_KEY`), an FB Connect address and the pricing flow's webhook id. + +1. As admin open Tech Admin and find the Market check card. It says the check is off. Enter the FB Connect address and + the webhook id, the store label ("Home Depot, Spokane WA: nearest store with live prices"), band 5 and floor 15, + and Save. The card now shows the id as "Saved" with Replace and Clear, and never the value. + `GET /api/v1/admin/market-check/settings` answers `webhook_id_saved: true` and no id. +2. Open Pricing, then Market check. The six top lumber products are picked. Remove one chip, add another with the + search, and press Run check. While it runs the rows are placeholders reading "Reading live prices through FB Connect". +3. Read the summary line and the table: each matched row shows our price, the Home Depot title and price with its + confidence and unit note, the difference in percent and dollars, the position and the suggested price with its + reason. An unmatched or error row shows its note across the row. +4. Check a suggestion by hand: a row above the band suggests the competitor's price plus the band, never under the + margin floor (cost divided by one less the floor percent). +5. As a cashier or a sales user, `POST /api/v1/pricing/market-check` is a 403. +6. Clear the id in Tech Admin and open Market check again: it says the check is off, with a link to Tech Admin. + None of the other walks of the plan are written yet; they land with their items. diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 710c3e45a..4babc355a 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -419,3 +419,4 @@ get an entry. | AI-ANTHROPIC-FIX1 | pim: `POST /api/v1/products/{id}/pim/generate/descriptions`, `/seo`, `/collateral`, `/image`; purchase_order: `POST /api/v1/purchase-orders/{id}/freight`; techadmin: `PUT /api/v1/admin/settings/ai` | With no AI key the PIM routes answered a 500 and the freight upload a bare 400 `Bad Request`; the PUT refused a provider alone with `api_key is required`, so the page could not switch back to OpenRouter | Every AI feature with no key (or a stored key the vault cannot open) answers 409 in the error envelope with the stable code `ai_off` and a plain message that AI is off and an administrator turns it on in Tech Admin, AI; image generation under the Anthropic provider is the same 409 naming that it needs the OpenRouter provider. The PUT accepts a provider or model alone. A save or delete takes effect at once. The intake's `synthetic_reason` no longer names an environment variable | | ACCOUNT-RULING-A | `GET /api/v1/accounts/{id}` | The account summary held the customer to the old finance wall: a user on a yard the customer is not linked to (by header, or with no header and grants that miss it) read it as 404 "account not found", although the Customers app lists every customer and the customer record opens (ruling A, ADR 0007 section 5.8) | The summary is the customer's dealer wide standing (balance, credit limit, open credits, unapplied cash; the same figures the counter's standing and the credit check read dealer wide, ADR 0007 section 5.8) and reads through ruling A's reach (`CustomerReadBranchForQuery`, `CustomerReadGrantsSubForQuery`): a user, an administrator and an unbound key reach every customer; a key bound to a branch keeps its pin and reads a customer outside it as 404, as before. Route, roles, body and error envelope are unchanged. Branch bound key class stays CONFINED (`CustomerBranch(path customer)`, `TestKeyBranchPin_PosAndAccountRows`). `GET /api/v1/accounts/{id}/transactions` and `GET /api/v1/ar/customers/{id}/statement` keep the finance documents' wall unchanged, as do each payment and credit memo: the subledger still answers 404 for a customer outside the caller's reach (the route now runs that wall as its own gate instead of through the summary), and the desk shows a calm note in the ledger's place | | DASH-AR-NET | `GET /api/v1/dashboard/summary` field `outstanding_ar` | The sum of `total_amount` over the yard's `UNPAID` and `PARTIAL` invoices, so part payments, open credit memos and unapplied cash were not netted and the figure disagreed with AR Aging for the same yard. | The yard's net open receivable as AR Aging reads it today (invoice open amounts, plus open credit memo amounts, less unapplied cash, inside the caller's wall). `outstanding_ar_count` is unchanged (open invoices). In the clockwindow golden `dashboard.summary.outstanding_ar` moved from 11865877 to 11056347; nothing else moved. | +| MARKET-CHECK (item 112 c) | `GET /api/v1/admin/market-check/settings`, `PUT /api/v1/admin/market-check/settings`, `POST /api/v1/pricing/market-check` (new, no migration) | No way to read our prices against a live competitor store. | The settings (FB Connect address, a sealed write only webhook id, flow name, store label, id and ZIP, band percent, margin floor percent) live in `system_settings` under `market_check.`, written by admin and owner (the machine key scope is `admin:settings`); the id is sealed with the payment vault and refused in words with no vault key, and is never answered, logged or audited. The run (admin and owner, the pricing rules' role wall) takes one to twelve distinct product ids, calls the configured flow in batches of three, and answers per product our walk in price, the competitor's match, our recomputed difference and position and a suggested price that keeps the margin floor (409 `not_configured` when unset). Cost is never sent or answered. Key allowlist: the run WALLED, the settings write REFUSED, the settings read a STATED LIMIT. No golden changes. | From 3e1d0f8a2675dc2f08f8423947fdc4ca38d07595 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 08:34:58 -0700 Subject: [PATCH 05/17] docs: walk heading level Signed-off-by: colton --- docs/parity/WALKS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/parity/WALKS.md b/docs/parity/WALKS.md index a7024e770..fefb49ca9 100644 --- a/docs/parity/WALKS.md +++ b/docs/parity/WALKS.md @@ -255,7 +255,7 @@ need. Save a PDF with `curl -s -o form.pdf` and read it with `pdftotext fo (`GET /api/v1/documents/print/purchase-order/`): the quote arrives in the catcher; the purchase order shows the vendor, the placing yard's address and its lines. -## Walk: Market check, our prices against a live store through FB Connect +### Market check, our prices against a live store through FB Connect Needs the demo seed, a vault key (`PAYMENT_VAULT_KEY`), an FB Connect address and the pricing flow's webhook id. From e2db368437d05911eab549cc260ba02c422bef2b Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 08:40:04 -0700 Subject: [PATCH 06/17] desk: the Market check screen, its Tech Admin card and the Pricing link Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- web/apps/desk/src/app.ts | 1 + .../pages/admin/pricing/MarketCheck.test.ts | 215 ++++++++++++ .../src/pages/admin/pricing/MarketCheck.ts | 326 ++++++++++++++++++ .../pages/admin/pricing/PricingMatrix.test.ts | 10 + .../src/pages/admin/pricing/PricingMatrix.ts | 5 + .../tech_admin/MarketCheckSettings.test.ts | 113 ++++++ .../admin/tech_admin/MarketCheckSettings.ts | 180 ++++++++++ .../pages/admin/tech_admin/TechAdminPage.ts | 7 +- web/apps/desk/src/routes.ts | 1 + .../desk/src/services/MarketCheckService.ts | 95 +++++ web/packages/api-client/src/schema.d.ts | 199 +++++++++++ 11 files changed, 1150 insertions(+), 2 deletions(-) create mode 100644 web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts create mode 100644 web/apps/desk/src/pages/admin/pricing/MarketCheck.ts create mode 100644 web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.test.ts create mode 100644 web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.ts create mode 100644 web/apps/desk/src/services/MarketCheckService.ts diff --git a/web/apps/desk/src/app.ts b/web/apps/desk/src/app.ts index 57c890449..447ec8c9f 100644 --- a/web/apps/desk/src/app.ts +++ b/web/apps/desk/src/app.ts @@ -140,6 +140,7 @@ export class GableApp extends LitElement { '/admin/branches': 'gable-admin-branches', '/admin/branches/:id/users': 'gable-admin-branch-users', '/pricing': 'gable-pricing-matrix', + '/pricing/market-check': 'gable-market-check', '/accounts': 'gable-accounts-page', '/accounts/:id': 'gable-account-detail', '/accounting/chart-of-accounts': 'gable-chart-of-accounts', diff --git a/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts b/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts new file mode 100644 index 000000000..777812f96 --- /dev/null +++ b/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The Market check screen: not configured, running, done, partial and all failed, the summary line, the + * table's words, the link attributes and the chips, against a stubbed fetch. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import './MarketCheck' +import { summarize } from './MarketCheck' +import type { GableMarketCheck } from './MarketCheck' +import { mount, update, flush, q, text, jsonResponse } from '../../../test/dom' + +const SETTINGS = { + connect_url: 'https://connect.example.test', + webhook_id_saved: true, + flow_name: 'Pricing flow', + store_label: 'Home Depot, Spokane WA: nearest store with live prices', + store_id: '', + store_zip: '', + band_pct: '5', + margin_floor_pct: '15', + configured: true, +} + +function product(n: number, sku = `DL_SPF_${n}`) { + return { id: `p-${n}`, sku, description: `Stud ${n}`, stock_uom: 'PCS' } +} + +function row(n: number, over: Record = {}) { + return { + product_id: `p-${n}`, sku: `DL_SPF_${n}`, description: `Stud ${n}`, uom: 'PCS', our_price_cents: 542, cost_known: true, + status: 'matched', + competitor: { title: `HD stud ${n}`, price_cents: 400, listed_price_cents: 4000, link: 'https://www.homedepot.com/p/1', confidence: 88, unit_note: 'Sold as a pack of 10; price divided by 10' }, + diff_pct: 35.5, position: 'above', suggested_price_cents: 420, reason: 'lower_to_band', note: '', ...over, + } +} + +function result(rows: unknown[]) { + return { flow_name: 'Pricing flow', store_label: SETTINGS.store_label, store_seen: 'Spokane', band_pct: '5', margin_floor_pct: '15', rows } +} + +let calls: { method: string; path: string; body: unknown }[] +let settings: Record +let runAnswer: () => Response + +beforeEach(() => { + localStorage.setItem('token', 'test-token') + calls = [] + settings = SETTINGS + runAnswer = () => jsonResponse(result([row(1)])) + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), 'http://localhost') + const method = init?.method ?? 'GET' + calls.push({ method, path: url.pathname, body: init?.body ? JSON.parse(String(init.body)) : undefined }) + if (url.pathname === '/api/v1/admin/market-check/settings') return jsonResponse(settings) + if (url.pathname === '/api/v1/products') { + return jsonResponse({ items: [1, 2, 3, 4, 5, 6, 7].map((n) => product(n)).concat([product(99, 'HW_NAILS')]), next_cursor: null, limit: 200 }) + } + if (url.pathname === '/api/v1/pricing/market-check') return runAnswer() + return jsonResponse({}) + })) +}) + +afterEach(() => { + document.body.innerHTML = '' + vi.unstubAllGlobals() +}) + +async function open(): Promise { + const el = await mount('gable-market-check') + await flush() + await update(el, {}) + return el +} + +async function run(el: GableMarketCheck) { + q(el, '[data-testid="run"]').click() + await flush() + await update(el, {}) + await flush() + await update(el, {}) +} + +describe('Market check screen', () => { + it('says it is off, with a link to Tech Admin, when not configured', async () => { + settings = { ...SETTINGS, configured: false, webhook_id_saved: false } + const el = await open() + expect(text(q(el, '[data-testid="not-configured"]'))).toContain( + "Market check is off. An admin enters the FB Connect address and the flow's webhook id in Tech Admin.", + ) + expect(q(el, '[data-testid="not-configured"] a').getAttribute('href')).toBe('/admin') + expect(el.querySelector('[data-testid="run"]')).toBeNull() + }) + + it('shows the header, the store label, the badge and the first pick of lumber products', async () => { + const el = await open() + expect(text(el)).toContain('Market check') + expect(text(q(el, '[data-testid="store-label"]'))).toBe(SETTINGS.store_label) + expect(text(q(el, '[data-testid="powered-by"]'))).toBe('Powered by FB Connect: Pricing flow') + const chips = text(q(el, '[data-testid="chips"]')) + expect(chips).toContain('DL_SPF_1') + expect(chips).toContain('DL_SPF_6') + expect(chips).not.toContain('DL_SPF_7') + expect(chips).not.toContain('HW_NAILS') + }) + + it('removes a chip, adds from the search, and runs with the picked ids', async () => { + const el = await open() + q(el, '[aria-label="Remove DL_SPF_1"]').click() + await update(el, {}) + const input = q(el, 'input[type="text"]') + input.value = 'HW_NAILS' + input.dispatchEvent(new Event('input')) + await update(el, {}) + q(el, '[data-testid="matches"] button').click() + await update(el, {}) + await run(el) + const post = calls.find((c) => c.method === 'POST')! + expect(post.body).toEqual({ product_ids: ['p-2', 'p-3', 'p-4', 'p-5', 'p-6', 'p-99'] }) + }) + + it('shows the placeholders and the reading words while it runs', async () => { + let release: (r: Response) => void = () => {} + runAnswer = () => { throw new Error('unused') } + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://localhost') + if (url.pathname === '/api/v1/admin/market-check/settings') return jsonResponse(SETTINGS) + if (url.pathname === '/api/v1/products') return jsonResponse({ items: [product(1)], next_cursor: null, limit: 200 }) + return new Promise((resolve) => { release = resolve }) + })) + const el = await open() + q(el, '[data-testid="run"]').click() + await flush() + await update(el, {}) + expect(text(q(el, '[data-testid="running"]'))).toContain('Reading live prices through FB Connect') + release(jsonResponse(result([row(1)]))) + await flush() + await update(el, {}) + expect(el.querySelector('[data-testid="running"]')).toBeNull() + }) + + it('renders the done table: words, link attributes, difference, position and the suggestion', async () => { + runAnswer = () => jsonResponse(result([ + row(1), + row(2, { position: 'below', diff_pct: -12.5, our_price_cents: 350, suggested_price_cents: 380, reason: 'raise_to_band' }), + row(3, { position: 'in_line', diff_pct: 0, our_price_cents: 400, suggested_price_cents: 400, reason: 'keep' }), + row(4, { suggested_price_cents: 589, reason: 'held_at_floor' }), + row(5, { status: 'unmatched', competitor: null, diff_pct: null, position: '', suggested_price_cents: null, reason: '', note: 'No equivalent found' }), + ])) + const el = await open() + await run(el) + expect(text(q(el, '[data-testid="summary"]'))).toBe( + '2 of 5 are above Home Depot by more than 5 percent, 1 is below, 1 is in line; 1 could not be matched', + ) + const body = text(q(el, 'tbody')) + expect(body).toContain('$5.42') + expect(body).toContain('HD stud 1') + expect(body).toContain('88%') + expect(body).toContain('Sold as a pack of 10; price divided by 10') + expect(body).toContain('+35.5%') + expect(body).toContain('+$1.42') + expect(body).toContain('Above') + expect(body).toContain('Below') + expect(body).toContain('In line') + expect(body).toContain('Within 5 percent of Home Depot') + expect(body).toContain('Raise to within 5 percent') + expect(body).toContain('Keep') + expect(body).toContain('Held at the 15 percent margin floor') + expect(body).toContain('No match: No equivalent found') + expect(text(q(el, '[data-testid="result"]'))).toContain('Home Depot prices are US list prices; no exchange is applied.') + const a = q(el, 'tbody a') + expect(a.getAttribute('target')).toBe('_blank') + expect(a.getAttribute('rel')).toBe('noopener noreferrer') + expect(a.getAttribute('href')).toBe('https://www.homedepot.com/p/1') + }) + + it('shows a title with no link as plain text', async () => { + runAnswer = () => jsonResponse(result([row(1, { competitor: { ...row(1).competitor, link: '' } })])) + const el = await open() + await run(el) + expect(el.querySelector('tbody a')).toBeNull() + expect(text(q(el, 'tbody'))).toContain('HD stud 1') + }) + + it('says some rows could not be read and keeps the rest (partial)', async () => { + runAnswer = () => jsonResponse(result([row(1), row(2, { status: 'error', competitor: null, diff_pct: null, position: '', suggested_price_cents: null, reason: '', note: 'FB Connect did not answer' })])) + const el = await open() + await run(el) + expect(text(q(el, '[data-testid="partial"]'))).toContain('Some products could not be read') + expect(text(q(el, 'tbody'))).toContain('FB Connect did not answer') + expect(el.querySelector('[data-testid="all-failed"]')).toBeNull() + expect(el.querySelector('[data-testid="summary"]')).not.toBeNull() + }) + + it('says none could be read when every row failed', async () => { + const bad = { status: 'error', competitor: null, diff_pct: null, position: '', suggested_price_cents: null, reason: '', note: 'FB Connect did not answer' } + runAnswer = () => jsonResponse(result([row(1, bad), row(2, bad)])) + const el = await open() + await run(el) + expect(text(q(el, '[data-testid="all-failed"]'))).toContain('None of the picked products could be read') + expect(el.querySelector('[data-testid="summary"]')).toBeNull() + }) + + it('shows the server words when the run itself is refused', async () => { + runAnswer = () => jsonResponse({ error: { code: 'validation_failed', message: 'pick between 1 and 12 products' } }, 400) + const el = await open() + await run(el) + expect(text(q(el, '[data-testid="run-error"]'))).toContain('pick between 1 and 12 products') + }) + + it('summarize counts singular and plural', () => { + expect(summarize(result([row(1)]) as never)).toBe('1 of 1 is above Home Depot by more than 5 percent, 0 are below, 0 are in line; 0 could not be matched') + }) +}) diff --git a/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts b/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts new file mode 100644 index 000000000..bd7e3dd58 --- /dev/null +++ b/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +import { LitElement, html, nothing } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { icon } from '../../../lib/icons'; +import { Radar, Play, X, AlertTriangle } from 'lucide'; +import { formatCents } from '../../../lib/utils'; +import { apiErrorMessage, ApiError } from '../../../services/apiError'; +import { ProductService, matchProducts } from '../../../services/product.service'; +import { marketCheckService } from '../../../services/MarketCheckService'; +import type { MarketCheckResult, MarketCheckRow, MarketCheckSettings } from '../../../services/MarketCheckService'; +import type { Product } from '../../../types/product'; + +/** The most products one check takes (the server's bound). */ +const MAX_PICK = 12; +/** On first open the pick is the first few lumber products. */ +const DEFAULT_PICK = 6; +const COMPETITOR = 'Home Depot'; + +interface Picked { + id: string; + sku: string; + description: string; +} + +/** "1 is below", "2 are below": the verb follows the count. */ +function countPhrase(n: number, what: string): string { + return `${n} ${n === 1 ? 'is' : 'are'} ${what}`; +} + +/** The summary line over a finished check. */ +export function summarize(res: MarketCheckResult): string { + const total = res.rows.length; + const count = (p: string) => res.rows.filter((r) => r.status === 'matched' && r.position === p).length; + const above = count('above'); + const below = count('below'); + const inLine = count('in_line'); + const lost = res.rows.filter((r) => r.status !== 'matched').length; + return ( + `${above} of ${total} ${above === 1 ? 'is' : 'are'} above ${COMPETITOR} by more than ${res.band_pct} percent, ` + + `${countPhrase(below, 'below')}, ${countPhrase(inLine, 'in line')}; ${lost} could not be matched` + ); +} + +/** The suggested price's reason in words. */ +export function reasonWords(row: MarketCheckRow, bandPct: string, floorPct: string): string { + switch (row.reason) { + case 'lower_to_band': + return `Within ${bandPct} percent of ${COMPETITOR}`; + case 'raise_to_band': + return `Raise to within ${bandPct} percent`; + case 'held_at_floor': + return `Held at the ${floorPct} percent margin floor`; + case 'keep': + return 'Keep'; + default: + return row.note; + } +} + +const POSITION_WORDS: Record = { above: 'Above', below: 'Below', in_line: 'In line' }; + +function signedPct(n: number): string { + return `${n > 0 ? '+' : ''}${n.toFixed(1)}%`; +} +function signedMoney(cents: number): string { + return `${cents > 0 ? '+' : cents < 0 ? '-' : ''}${formatCents(Math.abs(cents))}`; +} + +@customElement('gable-market-check') +export class GableMarketCheck extends LitElement { + createRenderRoot() { return this; } + + @state() private _settings: MarketCheckSettings | null = null; + @state() private _loading = true; + @state() private _loadError: string | null = null; + @state() private _picked: Picked[] = []; + @state() private _query = ''; + @state() private _catalog: Product[] = []; + @state() private _running = false; + @state() private _result: MarketCheckResult | null = null; + @state() private _runError: string | null = null; + /** Which source filled the first pick, said plainly on the page. */ + @state() private _pickSource = ''; + + connectedCallback() { + super.connectedCallback(); + void this._load(); + } + + private async _load() { + this._loading = true; + this._loadError = null; + try { + this._settings = await marketCheckService.getSettings(); + if (this._settings.configured) { + // The product list answers no category and no search, so the pick and the + // search both work over the catalogue the page loads once. + this._catalog = await ProductService.listAllProducts(); + const lumber = this._catalog.filter((p) => p.sku.toUpperCase().startsWith('DL_')); + const first = (lumber.length >= 1 ? lumber : this._catalog).slice(0, DEFAULT_PICK); + this._pickSource = lumber.length >= 1 ? 'the first lumber products' : 'the first products in the catalogue'; + this._picked = first.map((p) => ({ id: p.id, sku: p.sku, description: p.description })); + } + } catch (err) { + this._loadError = apiErrorMessage(err, 'Could not load the Market check'); + } finally { + this._loading = false; + } + } + + private _add(p: Product) { + if (this._picked.length >= MAX_PICK || this._picked.some((x) => x.id === p.id)) return; + this._picked = [...this._picked, { id: p.id, sku: p.sku, description: p.description }]; + this._query = ''; + } + + private _remove(id: string) { + this._picked = this._picked.filter((p) => p.id !== id); + } + + private async _run() { + if (this._picked.length === 0 || this._running) return; + this._running = true; + this._runError = null; + this._result = null; + try { + this._result = await marketCheckService.run(this._picked.map((p) => p.id)); + } catch (err) { + if (err instanceof ApiError && err.code === 'not_configured') { + await this._load(); + } else { + this._runError = apiErrorMessage(err, 'The market check could not run'); + } + } finally { + this._running = false; + } + } + + private _header() { + const s = this._settings; + const flow = this._result?.flow_name ?? s?.flow_name ?? ''; + const label = this._result?.store_label ?? s?.store_label ?? ''; + return html` +
+

+ ${icon(Radar, 32, 'w-8 h-8 text-gable-green')} + Market check +

+ ${label ? html`

${label}

` : nothing} + ${s?.configured + ? html`

Powered by FB Connect: ${flow}

` + : nothing} +
+ `; + } + + private _renderNotConfigured() { + return html` +
+

Market check is off.

+

+ An admin enters the FB Connect address and the flow's webhook id in + Tech Admin. +

+
+ `; + } + + private _renderPick() { + const matches = this._query.trim() + ? matchProducts(this._catalog, this._query) + .filter((p) => !this._picked.some((x) => x.id === p.id)) + .slice(0, 8) + : []; + return html` +
+
+ ${this._picked.map( + (p) => html` + + ${p.sku} + + + `, + )} + ${this._picked.length === 0 ? html`Pick up to ${MAX_PICK} products to check.` : nothing} +
+
+ = MAX_PICK} + @input=${(e: Event) => { this._query = (e.target as HTMLInputElement).value; }} + class="w-full bg-deep-space border border-white/10 rounded px-3 py-2 text-white text-sm placeholder-slate-500 focus:outline-none focus:border-gable-green" /> + ${matches.length > 0 ? html` +
    + ${matches.map((p) => html` +
  • + `)} +
` : nothing} +
+
+ + ${this._pickSource ? html`First pick: ${this._pickSource}.` : nothing} +
+
+ `; + } + + private _renderRunning() { + return html` +
+

Reading live prices through FB Connect

+ ${this._picked.map(() => html`
`)} +
+ `; + } + + private _renderRow(r: MarketCheckRow, res: MarketCheckResult) { + const c = r.competitor; + const head = html` +
${r.description}
${r.sku}
+ ${r.our_price_cents > 0 ? formatCents(r.our_price_cents) : ''} + `; + if (r.status !== 'matched' || !c) { + return html` + ${head} + + ${r.status === 'unmatched' ? 'No match: ' : ''}${r.note} + + `; + } + const diffCents = r.our_price_cents - c.price_cents; + return html` + ${head} + +
+ ${c.link + ? html`${c.title}` + : html`${c.title}`} +
+
${formatCents(c.price_cents)} + ${c.confidence}%
+ ${c.unit_note ? html`
${c.unit_note}
` : nothing} + + + ${r.diff_pct != null ? signedPct(r.diff_pct) : ''} ${signedMoney(diffCents)} + + ${POSITION_WORDS[r.position] ?? ''} + + ${r.suggested_price_cents != null + ? html`
${formatCents(r.suggested_price_cents)}
+
${reasonWords(r, res.band_pct, res.margin_floor_pct)}
` + : html`
${r.note}
`} + + `; + } + + private _renderResult(res: MarketCheckResult) { + const allFailed = res.rows.length > 0 && res.rows.every((r) => r.status === 'error'); + const someFailed = !allFailed && res.rows.some((r) => r.status === 'error'); + return html` +
+ ${allFailed + ? html`
+ ${icon(AlertTriangle, 14)} None of the picked products could be read. Check the FB Connect settings in Tech Admin and try again.
` + : html`

${summarize(res)}

`} + ${someFailed + ? html`

Some products could not be read; the rest stand.

` + : nothing} + ${res.store_seen ? html`

Store read: ${res.store_seen}

` : nothing} +
+ + + + + + + + + + + + ${res.rows.map((r) => this._renderRow(r, res))} +
ProductOur price${COMPETITOR}DifferencePositionSuggested
+
+

${COMPETITOR} prices are US list prices; no exchange is applied.

+
+ `; + } + + render() { + let body; + if (this._loading) { + body = html`

Loading Market check

`; + } else if (this._loadError) { + body = html`
${this._loadError}
`; + } else if (!this._settings?.configured) { + body = this._renderNotConfigured(); + } else { + body = html` + ${this._renderPick()} + ${this._runError ? html`
${this._runError}
` : nothing} + ${this._running ? this._renderRunning() : this._result ? this._renderResult(this._result) : nothing} + `; + } + return html` +
+ ${this._header()} + ${body} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gable-market-check': GableMarketCheck; + } +} diff --git a/web/apps/desk/src/pages/admin/pricing/PricingMatrix.test.ts b/web/apps/desk/src/pages/admin/pricing/PricingMatrix.test.ts index aaf187066..16ba11adb 100644 --- a/web/apps/desk/src/pages/admin/pricing/PricingMatrix.test.ts +++ b/web/apps/desk/src/pages/admin/pricing/PricingMatrix.test.ts @@ -111,6 +111,16 @@ describe('PricingMatrix', () => { expect(consoleError).not.toHaveBeenCalled() }) + it('links to the Market check, switched off or on', async () => { + respond = () => new Response('404 page not found', { status: 404, headers: { 'Content-Type': 'text/plain' } }) + const off = await open() + expect(q(off, '[data-testid="market-check-link"]').getAttribute('href')).toBe('/pricing/market-check') + off.remove() + respond = (method) => (method === 'GET' ? jsonResponse(matrix([DIRECT])) : jsonResponse({})) + const on = await open() + expect(q(on, '[data-testid="market-check-link"]').getAttribute('href')).toBe('/pricing/market-check') + }) + it.each([500, 403])('keeps the failure banner, and logs it, for a %i (only a 404 means switched off)', async (status) => { respond = () => new Response(JSON.stringify({ error: { code: 'internal_error', message: 'boom' }, meta: { request_id: 'r' } }), { status, headers: { 'Content-Type': 'application/json' } }) const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/web/apps/desk/src/pages/admin/pricing/PricingMatrix.ts b/web/apps/desk/src/pages/admin/pricing/PricingMatrix.ts index 7639e8795..bf902f1aa 100644 --- a/web/apps/desk/src/pages/admin/pricing/PricingMatrix.ts +++ b/web/apps/desk/src/pages/admin/pricing/PricingMatrix.ts @@ -284,6 +284,7 @@ export class GablePricingMatrix extends LitElement { ${icon(LayoutGrid, 32, 'w-8 h-8 text-gable-green')} Pricing Matrix + Market check

Category pricing is off on this server

@@ -309,6 +310,10 @@ export class GablePricingMatrix extends LitElement {

+ + Market check + ${this._activeTab === 'matrix' && !this._bulkMode ? html` + +
` + : nothing} + ${showInput + ? html`
+ ${icon(Shield, 16, 'absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500')} + { this._newId = (e.target as HTMLInputElement).value; }} + class="${FIELD_CLASS} pl-10 font-mono" /> +
+ ${this._fieldErrors.webhook_id ? html`${this._fieldErrors.webhook_id}` : nothing}` + : nothing} + + +
+ ${this._field('flow_name', 'Flow name', 'FB Connect pricing flow')} + ${this._field('store_label', 'Store label', 'Home Depot, Spokane WA: nearest store with live prices')} + ${this._field('store_id', 'Store id', '', true)} + ${this._field('store_zip', 'Store ZIP', '', true)} + ${this._field('band_pct', 'Band percent', '5', true)} + ${this._field('margin_floor_pct', 'Margin floor percent', '15', true)} +
+ +
+ +
+ + + ${this._error ? html`
${this._error}
` : nothing} + ${this._success ? html`
${this._success}
` : nothing} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gable-market-check-settings': GableMarketCheckSettings; + } +} diff --git a/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts b/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts index f9560047e..af1422dd8 100644 --- a/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts +++ b/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts @@ -5,11 +5,12 @@ import { LitElement, html, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { icon } from '../../../lib/icons.ts'; import { cn } from '../../../lib/utils.ts'; +import './MarketCheckSettings.ts'; import { techAdminService, ediService } from '../../../services/TechAdminService'; import type { APIKey, AISettings, RoutingSettings, EDITradingPartner, StaffMember, ModuleInfo, Readiness } from '../../../services/TechAdminService'; import { Key, Globe, Activity, Plus, Trash2, Copy, Check, Eye, Sparkles, - Shield, AlertCircle, Image as ImageIcon, Network, Power, RefreshCw, Navigation, Users + Shield, AlertCircle, Image as ImageIcon, Network, Power, RefreshCw, Navigation, Users, Radar } from 'lucide'; const AI_LM_MODULE_ID = 'ai_lm'; @@ -18,7 +19,7 @@ const AI_LM_MODULE_ID = 'ai_lm'; export class TechAdminPage extends LitElement { createRenderRoot() { return this; } - @state() private activeTab: 'keys' | 'ai' | 'routing' | 'staff' | 'integrations' | 'health' = 'keys'; + @state() private activeTab: 'keys' | 'ai' | 'market-check' | 'routing' | 'staff' | 'integrations' | 'health' = 'keys'; // API Key Manager state @state() private keys: APIKey[] = []; @@ -424,6 +425,7 @@ export class TechAdminPage extends LitElement { const tabs = [ { id: 'keys' as const, label: 'API Keys', iconData: Key }, { id: 'ai' as const, label: 'AI Settings', iconData: Sparkles }, + { id: 'market-check' as const, label: 'Market check', iconData: Radar }, { id: 'routing' as const, label: 'Routing', iconData: Navigation }, { id: 'staff' as const, label: 'Staff', iconData: Users }, { id: 'integrations' as const, label: 'Integrations', iconData: Globe }, @@ -1091,6 +1093,7 @@ export class TechAdminPage extends LitElement {
${this.activeTab === 'keys' ? this._renderAPIKeyManager() : nothing} ${this.activeTab === 'ai' ? this._renderAISettingsPanel() : nothing} + ${this.activeTab === 'market-check' ? html`` : nothing} ${this.activeTab === 'routing' ? this._renderRoutingSettingsPanel() : nothing} ${this.activeTab === 'staff' ? this._renderStaff() : nothing} ${this.activeTab === 'integrations' ? this._renderIntegrations() : nothing} diff --git a/web/apps/desk/src/routes.ts b/web/apps/desk/src/routes.ts index b6629052d..bbe013953 100644 --- a/web/apps/desk/src/routes.ts +++ b/web/apps/desk/src/routes.ts @@ -83,6 +83,7 @@ export const routes: RouteConfig[] = [ { path: '/admin/branches', load: () => import('./pages/admin/branches/Branches.ts'), layout: 'erp' }, { path: '/admin/market-indices', load: () => import('./pages/admin/MarketIndices.ts'), layout: 'erp' }, { path: '/admin', load: () => import('./pages/admin/tech_admin/TechAdminPage.ts'), layout: 'erp' }, + { path: '/pricing/market-check', load: () => import('./pages/admin/pricing/MarketCheck.ts'), layout: 'erp' }, { path: '/pricing', load: () => import('./pages/admin/pricing/PricingMatrix.ts'), layout: 'erp' }, { path: '/accounts/:id', load: () => import('./pages/accounts/AccountDetailPage.ts'), layout: 'erp' }, { path: '/accounts', load: () => import('./pages/accounts/AccountsPage.ts'), layout: 'erp' }, diff --git a/web/apps/desk/src/services/MarketCheckService.ts b/web/apps/desk/src/services/MarketCheckService.ts new file mode 100644 index 000000000..db155929b --- /dev/null +++ b/web/apps/desk/src/services/MarketCheckService.ts @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The Market check wire (item 112 c): the Tech Admin settings and the run. The webhook id + * is write only: no answer of these routes carries it. Every failure is an ApiError. + */ +import { fetchWithAuth } from './fetchClient'; +import { parseApiError } from './apiError'; + +const API_URL = import.meta.env.VITE_API_URL || ''; + +export interface MarketCheckSettings { + connect_url: string; + webhook_id_saved: boolean; + flow_name: string; + store_label: string; + store_id: string; + store_zip: string; + band_pct: string; + margin_floor_pct: string; + configured: boolean; +} + +/** A field left out keeps what is saved; webhook_id '' clears the saved id. */ +export type MarketCheckSettingsInput = Partial> & { + webhook_id?: string; +}; + +export type MarketCheckPosition = '' | 'above' | 'below' | 'in_line'; +export type MarketCheckReason = '' | 'lower_to_band' | 'raise_to_band' | 'held_at_floor' | 'keep' | 'no_cost'; + +export interface MarketCheckCompetitor { + title: string; + price_cents: number; + listed_price_cents: number; + link: string; + confidence: number; + unit_note: string; +} + +export interface MarketCheckRow { + product_id: string; + sku: string; + description: string; + uom: string; + our_price_cents: number; + cost_known: boolean; + status: 'matched' | 'unmatched' | 'error'; + competitor: MarketCheckCompetitor | null; + diff_pct: number | null; + position: MarketCheckPosition; + suggested_price_cents: number | null; + reason: MarketCheckReason; + note: string; +} + +export interface MarketCheckResult { + flow_name: string; + store_label: string; + store_seen: string | null; + band_pct: string; + margin_floor_pct: string; + rows: MarketCheckRow[]; +} + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +export const marketCheckService = { + async getSettings(): Promise { + const response = await fetchWithAuth(`${API_URL}/api/v1/admin/market-check/settings`); + if (!response.ok) throw await parseApiError(response, 'Could not read the Market check settings'); + return response.json(); + }, + + async saveSettings(input: MarketCheckSettingsInput): Promise { + const response = await fetchWithAuth(`${API_URL}/api/v1/admin/market-check/settings`, { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify(input), + }); + if (!response.ok) throw await parseApiError(response, 'Could not save the Market check settings'); + return response.json(); + }, + + async run(productIds: string[]): Promise { + const response = await fetchWithAuth(`${API_URL}/api/v1/pricing/market-check`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ product_ids: productIds }), + }); + if (!response.ok) throw await parseApiError(response, 'The market check could not run'); + return response.json(); + }, +}; diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index 2ece9a175..0bc02d07d 100644 --- a/web/packages/api-client/src/schema.d.ts +++ b/web/packages/api-client/src/schema.d.ts @@ -3456,6 +3456,50 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/market-check/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Show the Market check settings + * @description Admin or owner. The FB Connect address, whether a webhook id is saved (never the id), the flow's name, the comparison store's label, id and ZIP, the band percent and the margin floor percent. configured is true when the address and the id are both saved. + */ + get: operations["marketCheckGetSettings"]; + /** + * Save the Market check settings + * @description Admin or owner. A field left out keeps what is saved. webhook_id absent keeps the saved id, an empty string clears it; a new id is sealed with the vault (refused in words when no vault key is configured). The address is https://host[:port] with no user info, path, query or fragment (http only for a loopback host). Answers the GET shape. + */ + put: operations["marketCheckSaveSettings"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/pricing/market-check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Check picked products against the live store + * @description Admin or owner. Runs the FB Connect pricing flow for one to twelve distinct products in batches of three. Per product: our walk in price, the competitor's matched title and price, the difference and position recomputed from our own figures, and a suggested price that keeps the margin floor. A batch that fails marks only its own items error. 409 not_configured when the address or the webhook id is not saved. Cost is never in the answer and is never sent to the flow. + */ + post: operations["marketCheckRun"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/matching/config": { parameters: { query?: never; @@ -10445,6 +10489,73 @@ export interface components { branch_id: string; }; StringList: string[]; + MarketCheckSettings: { + connect_url: string; + webhook_id_saved: boolean; + flow_name: string; + store_label: string; + store_id: string; + store_zip: string; + /** @description A decimal percent from 0 to 50. */ + band_pct: string; + /** @description A decimal percent from 0 to 90. */ + margin_floor_pct: string; + configured: boolean; + }; + MarketCheckSettingsRequest: { + connect_url?: string; + /** @description Write only. Absent keeps the saved id; an empty string clears it. */ + webhook_id?: string; + flow_name?: string; + store_label?: string; + store_id?: string; + store_zip?: string; + band_pct?: string; + margin_floor_pct?: string; + }; + MarketCheckRunRequest: { + product_ids: string[]; + }; + MarketCheckCompetitor: { + title: string; + /** Format: int64 */ + price_cents: number; + /** Format: int64 */ + listed_price_cents: number; + /** @description Kept only when it starts with https://www.homedepot.com/ */ + link: string; + confidence: number; + unit_note: string; + }; + MarketCheckRow: { + /** Format: uuid */ + product_id: string; + sku: string; + description: string; + uom: string; + /** Format: int64 */ + our_price_cents: number; + cost_known: boolean; + /** @enum {string} */ + status: "matched" | "unmatched" | "error"; + competitor: components["schemas"]["MarketCheckCompetitor"] | null; + diff_pct: number | null; + /** @enum {string} */ + position: "" | "above" | "below" | "in_line"; + /** Format: int64 */ + suggested_price_cents: number | null; + /** @enum {string} */ + reason: "" | "lower_to_band" | "raise_to_band" | "held_at_floor" | "keep" | "no_cost"; + note: string; + }; + MarketCheckResult: { + flow_name: string; + store_label: string; + store_seen: string | null; + band_pct: string; + margin_floor_pct: string; + rows: components["schemas"]["MarketCheckRow"][]; + }; /** @enum {string} */ MatchingStatus: "PENDING" | "MATCHED" | "PARTIAL" | "EXCEPTION"; MatchingResult: { @@ -22250,6 +22361,94 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; + marketCheckGetSettings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The settings. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MarketCheckSettings"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 500: components["responses"]["WireInternalError"]; + }; + }; + marketCheckSaveSettings: { + parameters: { + query?: never; + header?: { + /** @description Opt in idempotent replay for POST, PUT and PATCH. The legacy name X-Idempotency-Key addresses the same claim. Claims are stored in Postgres and survive a restart; only 2xx and 3xx responses are stored. */ + "Idempotency-Key"?: components["parameters"]["IdempotencyKey"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MarketCheckSettingsRequest"]; + }; + }; + responses: { + /** @description The settings after the save. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MarketCheckSettings"]; + }; + }; + 400: components["responses"]["WireBadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 413: components["responses"]["PayloadTooLarge"]; + 500: components["responses"]["WireInternalError"]; + }; + }; + marketCheckRun: { + parameters: { + query?: never; + header?: { + /** @description Opt in idempotent replay for POST, PUT and PATCH. The legacy name X-Idempotency-Key addresses the same claim. Claims are stored in Postgres and survive a restart; only 2xx and 3xx responses are stored. */ + "Idempotency-Key"?: components["parameters"]["IdempotencyKey"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MarketCheckRunRequest"]; + }; + }; + responses: { + /** @description One row per picked product, in request order. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MarketCheckResult"]; + }; + }; + 400: components["responses"]["WireBadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 409: components["responses"]["WireConflict"]; + 413: components["responses"]["PayloadTooLarge"]; + 500: components["responses"]["WireInternalError"]; + }; + }; matchingConfigGet: { parameters: { query?: never; From 8b8efdd72d8a2a1c00bb4b6ed7596d1e37e5ef51 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 08:40:23 -0700 Subject: [PATCH 07/17] serve: gofmt the import order Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/app/serve/serve.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/internal/app/serve/serve.go b/core/internal/app/serve/serve.go index 660743671..77258db72 100644 --- a/core/internal/app/serve/serve.go +++ b/core/internal/app/serve/serve.go @@ -48,6 +48,7 @@ import ( "github.com/gablelbm/gable/internal/invoice" "github.com/gablelbm/gable/internal/links" "github.com/gablelbm/gable/internal/location" + "github.com/gablelbm/gable/internal/marketcheck" "github.com/gablelbm/gable/internal/matching" "github.com/gablelbm/gable/internal/millwork" "github.com/gablelbm/gable/internal/notification" @@ -58,7 +59,6 @@ import ( "github.com/gablelbm/gable/internal/pim" "github.com/gablelbm/gable/internal/portal" "github.com/gablelbm/gable/internal/pos" - "github.com/gablelbm/gable/internal/marketcheck" "github.com/gablelbm/gable/internal/pricing" "github.com/gablelbm/gable/internal/product" "github.com/gablelbm/gable/internal/project" From eeb01da7f51f8d450b82677c5d28318ec7e6f13d Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 08:46:40 -0700 Subject: [PATCH 08/17] key allowlist: the Market check run is refused to a branch bound key Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/pkg/middleware/keybranch_allow.go | 2 +- docs/refactor/CONTRACT-CHANGES.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 2b2a466bf..081c9a0e9 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -92,7 +92,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/ar/customers/{id}/statement/email": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_DocumentRoutes"}, "GET /api/v1/documents/print/statement/{id}": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_DocumentRoutes"}, "GET /api/v1/branches/{id}/users": {KeyBranchClassConfined, "NamedBranch(id); TestKeyBranchPin_LocationReads"}, - "POST /api/v1/pricing/market-check": {KeyBranchClassWalled, "the branch middleware holds the pin: the yard the flow is told and the caller's role come from branch context; the answer carries no cost; TestMarketCheckWire"}, + "POST /api/v1/pricing/market-check": {KeyBranchClassRefused, "a run spends the dealer's FB Connect flow and reads the whole catalogue with the caller's yard as context; a bound key has no yard session"}, "GET /api/v1/pricing/calculate": {KeyBranchClassConfined, "CustomerBranch(query customer_id, and job_id to projects.customer_id); TestKeyBranchPin_CustomerConfinedReads"}, "GET /api/v1/pricing/category-rules": {KeyBranchClassConfined, "CustomerBranch(query customer_id, required); TestKeyBranchPin_CustomerConfinedReads"}, "GET /api/v1/pricing/category-rules/{id}/audit": {KeyBranchClassConfined, "CustomerRowBranch, tier rows refused; TestKeyBranchPin_CategoryTierRefusals"}, diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 4babc355a..a9d268373 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -419,4 +419,4 @@ get an entry. | AI-ANTHROPIC-FIX1 | pim: `POST /api/v1/products/{id}/pim/generate/descriptions`, `/seo`, `/collateral`, `/image`; purchase_order: `POST /api/v1/purchase-orders/{id}/freight`; techadmin: `PUT /api/v1/admin/settings/ai` | With no AI key the PIM routes answered a 500 and the freight upload a bare 400 `Bad Request`; the PUT refused a provider alone with `api_key is required`, so the page could not switch back to OpenRouter | Every AI feature with no key (or a stored key the vault cannot open) answers 409 in the error envelope with the stable code `ai_off` and a plain message that AI is off and an administrator turns it on in Tech Admin, AI; image generation under the Anthropic provider is the same 409 naming that it needs the OpenRouter provider. The PUT accepts a provider or model alone. A save or delete takes effect at once. The intake's `synthetic_reason` no longer names an environment variable | | ACCOUNT-RULING-A | `GET /api/v1/accounts/{id}` | The account summary held the customer to the old finance wall: a user on a yard the customer is not linked to (by header, or with no header and grants that miss it) read it as 404 "account not found", although the Customers app lists every customer and the customer record opens (ruling A, ADR 0007 section 5.8) | The summary is the customer's dealer wide standing (balance, credit limit, open credits, unapplied cash; the same figures the counter's standing and the credit check read dealer wide, ADR 0007 section 5.8) and reads through ruling A's reach (`CustomerReadBranchForQuery`, `CustomerReadGrantsSubForQuery`): a user, an administrator and an unbound key reach every customer; a key bound to a branch keeps its pin and reads a customer outside it as 404, as before. Route, roles, body and error envelope are unchanged. Branch bound key class stays CONFINED (`CustomerBranch(path customer)`, `TestKeyBranchPin_PosAndAccountRows`). `GET /api/v1/accounts/{id}/transactions` and `GET /api/v1/ar/customers/{id}/statement` keep the finance documents' wall unchanged, as do each payment and credit memo: the subledger still answers 404 for a customer outside the caller's reach (the route now runs that wall as its own gate instead of through the summary), and the desk shows a calm note in the ledger's place | | DASH-AR-NET | `GET /api/v1/dashboard/summary` field `outstanding_ar` | The sum of `total_amount` over the yard's `UNPAID` and `PARTIAL` invoices, so part payments, open credit memos and unapplied cash were not netted and the figure disagreed with AR Aging for the same yard. | The yard's net open receivable as AR Aging reads it today (invoice open amounts, plus open credit memo amounts, less unapplied cash, inside the caller's wall). `outstanding_ar_count` is unchanged (open invoices). In the clockwindow golden `dashboard.summary.outstanding_ar` moved from 11865877 to 11056347; nothing else moved. | -| MARKET-CHECK (item 112 c) | `GET /api/v1/admin/market-check/settings`, `PUT /api/v1/admin/market-check/settings`, `POST /api/v1/pricing/market-check` (new, no migration) | No way to read our prices against a live competitor store. | The settings (FB Connect address, a sealed write only webhook id, flow name, store label, id and ZIP, band percent, margin floor percent) live in `system_settings` under `market_check.`, written by admin and owner (the machine key scope is `admin:settings`); the id is sealed with the payment vault and refused in words with no vault key, and is never answered, logged or audited. The run (admin and owner, the pricing rules' role wall) takes one to twelve distinct product ids, calls the configured flow in batches of three, and answers per product our walk in price, the competitor's match, our recomputed difference and position and a suggested price that keeps the margin floor (409 `not_configured` when unset). Cost is never sent or answered. Key allowlist: the run WALLED, the settings write REFUSED, the settings read a STATED LIMIT. No golden changes. | +| MARKET-CHECK (item 112 c) | `GET /api/v1/admin/market-check/settings`, `PUT /api/v1/admin/market-check/settings`, `POST /api/v1/pricing/market-check` (new, no migration) | No way to read our prices against a live competitor store. | The settings (FB Connect address, a sealed write only webhook id, flow name, store label, id and ZIP, band percent, margin floor percent) live in `system_settings` under `market_check.`, written by admin and owner (the machine key scope is `admin:settings`); the id is sealed with the payment vault and refused in words with no vault key, and is never answered, logged or audited. The run (admin and owner, the pricing rules' role wall) takes one to twelve distinct product ids, calls the configured flow in batches of three, and answers per product our walk in price, the competitor's match, our recomputed difference and position and a suggested price that keeps the margin floor (409 `not_configured` when unset). Cost is never sent or answered. Key allowlist: the run and the settings write REFUSED to a branch bound key, the settings read a STATED LIMIT. No golden changes. | From 9cda2720c11efad652733aff51c326260d001e02 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 09:02:07 -0700 Subject: [PATCH 09/17] desk: the Market check run waits 90 seconds and reads an abort in words The run called fetchWithAuth with its default 10 second timeout, so a cold FB Connect run was aborted and cancelled the server's batches. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../src/services/MarketCheckService.test.ts | 38 +++++++++++++++++++ .../desk/src/services/MarketCheckService.ts | 28 +++++++++++--- 2 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 web/apps/desk/src/services/MarketCheckService.test.ts diff --git a/web/apps/desk/src/services/MarketCheckService.test.ts b/web/apps/desk/src/services/MarketCheckService.test.ts new file mode 100644 index 000000000..12fb11336 --- /dev/null +++ b/web/apps/desk/src/services/MarketCheckService.test.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** The run outlives the default 10 second fetch timeout (a cold FB Connect run takes longer), and an abort reads in words. */ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +const fetchWithAuth = vi.fn() +vi.mock('./fetchClient', () => ({ fetchWithAuth: (...a: unknown[]) => fetchWithAuth(...a) })) + +import { marketCheckService, RUN_TIMEOUT_MS, TOO_SLOW_WORDS } from './MarketCheckService' + +beforeEach(() => fetchWithAuth.mockReset()) + +describe('marketCheckService.run', () => { + it('passes a timeout above the server worst case (batches of up to 30 seconds)', async () => { + fetchWithAuth.mockResolvedValue(new Response(JSON.stringify({ rows: [] }), { status: 200 })) + await marketCheckService.run(['p-1']) + const init = fetchWithAuth.mock.calls[0][1] as { timeout?: number } + expect(init.timeout).toBe(RUN_TIMEOUT_MS) + expect(RUN_TIMEOUT_MS).toBeGreaterThanOrEqual(90_000) + }) + + it.each([ + ['AbortError', 'signal is aborted without reason'], + ['TimeoutError', 'The operation timed out'], + ])('reads a %s in words', async (name, message) => { + const err = new Error(message) + err.name = name + fetchWithAuth.mockRejectedValue(err) + await expect(marketCheckService.run(['p-1'])).rejects.toThrow(TOO_SLOW_WORDS) + expect(TOO_SLOW_WORDS).toBe('FB Connect took too long to answer; try again') + }) + + it('leaves another failure as it is', async () => { + fetchWithAuth.mockRejectedValue(new TypeError('Failed to fetch')) + await expect(marketCheckService.run(['p-1'])).rejects.toThrow('Failed to fetch') + }) +}) diff --git a/web/apps/desk/src/services/MarketCheckService.ts b/web/apps/desk/src/services/MarketCheckService.ts index db155929b..69610977f 100644 --- a/web/apps/desk/src/services/MarketCheckService.ts +++ b/web/apps/desk/src/services/MarketCheckService.ts @@ -64,6 +64,14 @@ export interface MarketCheckResult { rows: MarketCheckRow[]; } +/** + * A run can take a while: the server reads in batches of up to 30 seconds each, and a cold FB Connect + * run takes longer than the fetch client's default 10 seconds, so the run opts into a longer budget. + */ +export const RUN_TIMEOUT_MS = 90_000; + +export const TOO_SLOW_WORDS = 'FB Connect took too long to answer; try again'; + const JSON_HEADERS = { 'Content-Type': 'application/json' }; export const marketCheckService = { @@ -84,11 +92,21 @@ export const marketCheckService = { }, async run(productIds: string[]): Promise { - const response = await fetchWithAuth(`${API_URL}/api/v1/pricing/market-check`, { - method: 'POST', - headers: JSON_HEADERS, - body: JSON.stringify({ product_ids: productIds }), - }); + let response: Response; + try { + response = await fetchWithAuth(`${API_URL}/api/v1/pricing/market-check`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ product_ids: productIds }), + timeout: RUN_TIMEOUT_MS, + }); + } catch (err) { + // The fetch client aborts at its timeout; an abort reads in words, not as "signal is aborted". + if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError')) { + throw new Error(TOO_SLOW_WORDS); + } + throw err; + } if (!response.ok) throw await parseApiError(response, 'The market check could not run'); return response.json(); }, From e8e776c7e3c850030b1cc17fb1880f1a0a4bc80d Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 09:02:59 -0700 Subject: [PATCH 10/17] desk: make the Market check service test catch the abort legs Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../src/services/MarketCheckService.test.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/web/apps/desk/src/services/MarketCheckService.test.ts b/web/apps/desk/src/services/MarketCheckService.test.ts index 12fb11336..9b7bf87db 100644 --- a/web/apps/desk/src/services/MarketCheckService.test.ts +++ b/web/apps/desk/src/services/MarketCheckService.test.ts @@ -9,7 +9,16 @@ vi.mock('./fetchClient', () => ({ fetchWithAuth: (...a: unknown[]) => fetchWithA import { marketCheckService, RUN_TIMEOUT_MS, TOO_SLOW_WORDS } from './MarketCheckService' -beforeEach(() => fetchWithAuth.mockReset()) +beforeEach(() => { fetchWithAuth.mockClear() }) + +async function failure(): Promise { + try { + await marketCheckService.run(['p-1']) + } catch (e) { + return (e as Error).message + } + return '' +} describe('marketCheckService.run', () => { it('passes a timeout above the server worst case (batches of up to 30 seconds)', async () => { @@ -26,13 +35,13 @@ describe('marketCheckService.run', () => { ])('reads a %s in words', async (name, message) => { const err = new Error(message) err.name = name - fetchWithAuth.mockRejectedValue(err) - await expect(marketCheckService.run(['p-1'])).rejects.toThrow(TOO_SLOW_WORDS) + fetchWithAuth.mockImplementation(() => { throw err }) + expect(await failure()).toBe(TOO_SLOW_WORDS) expect(TOO_SLOW_WORDS).toBe('FB Connect took too long to answer; try again') }) it('leaves another failure as it is', async () => { - fetchWithAuth.mockRejectedValue(new TypeError('Failed to fetch')) - await expect(marketCheckService.run(['p-1'])).rejects.toThrow('Failed to fetch') + fetchWithAuth.mockImplementation(() => { throw new TypeError("Failed to fetch") }) + expect(await failure()).toBe('Failed to fetch') }) }) From 6f7d3714e817896e26887e2916d77ca3e690c4b1 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 09:03:54 -0700 Subject: [PATCH 11/17] desk: the Market check card keeps every field set before a re-render Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../tech_admin/MarketCheckSettings.test.ts | 18 ++++++++++++++++++ .../admin/tech_admin/MarketCheckSettings.ts | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.test.ts b/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.test.ts index 2e86374bc..d65ff6f2d 100644 --- a/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.test.ts +++ b/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.test.ts @@ -87,6 +87,24 @@ describe('Market check settings card', () => { expect(el.innerHTML).not.toContain('test-hook-0002') }) + it('keeps every field set before a re-render (autofill sets several at once)', async () => { + const el = await open() + const set = (name: string, value: string) => { + const input = q(el, `input[name="${name}"]`) + input.value = value + input.dispatchEvent(new Event('input')) + } + set('connect_url', 'https://other.example.test') + set('flow_name', 'Autofilled flow') + set('store_label', 'Autofilled store') + set('store_zip', '99201') + await update(el, {}) + await click(el, '[data-testid="save"]') + expect(puts[0]).toMatchObject({ + connect_url: 'https://other.example.test', flow_name: 'Autofilled flow', store_label: 'Autofilled store', store_zip: '99201', + }) + }) + it('a plain save leaves the id out so the server keeps it', async () => { const el = await open() await click(el, '[data-testid="save"]') diff --git a/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.ts b/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.ts index 01cd21b0f..561b22fe2 100644 --- a/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.ts +++ b/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.ts @@ -95,7 +95,8 @@ export class GableMarketCheckSettings extends LitElement { From e9c0a037c27ae0c34a38b25437cc1c3b22380633 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 09:03:54 -0700 Subject: [PATCH 12/17] desk: the first pick is the yard's top Lumber sellers from the counter tiles Falls back to the first catalogue products when the route is absent; the SKU prefix assumption is gone, and the page says which source it used. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- docs/parity/WALKS.md | 2 +- .../pages/admin/pricing/MarketCheck.test.ts | 40 ++++++++++++++++++- .../src/pages/admin/pricing/MarketCheck.ts | 27 ++++++++++--- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/docs/parity/WALKS.md b/docs/parity/WALKS.md index fefb49ca9..61d0fdd4e 100644 --- a/docs/parity/WALKS.md +++ b/docs/parity/WALKS.md @@ -263,7 +263,7 @@ Needs the demo seed, a vault key (`PAYMENT_VAULT_KEY`), an FB Connect address an the webhook id, the store label ("Home Depot, Spokane WA: nearest store with live prices"), band 5 and floor 15, and Save. The card now shows the id as "Saved" with Replace and Clear, and never the value. `GET /api/v1/admin/market-check/settings` answers `webhook_id_saved: true` and no id. -2. Open Pricing, then Market check. The six top lumber products are picked. Remove one chip, add another with the +2. Open Pricing, then Market check. The yard's six top lumber sellers are picked (the counter tiles; the first six catalogue products when that route is absent), and the page says which. Remove one chip, add another with the search, and press Run check. While it runs the rows are placeholders reading "Reading live prices through FB Connect". 3. Read the summary line and the table: each matched row shows our price, the Home Depot title and price with its confidence and unit note, the difference in percent and dollars, the position and the suggested price with its diff --git a/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts b/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts index 777812f96..6f906b2c5 100644 --- a/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts +++ b/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts @@ -43,17 +43,26 @@ function result(rows: unknown[]) { let calls: { method: string; path: string; body: unknown }[] let settings: Record let runAnswer: () => Response +/** The counter's tiles answer; null stands for a server without the route (404). */ +let tiles: unknown[] | null beforeEach(() => { localStorage.setItem('token', 'test-token') calls = [] settings = SETTINGS + tiles = null runAnswer = () => jsonResponse(result([row(1)])) vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(String(input), 'http://localhost') const method = init?.method ?? 'GET' calls.push({ method, path: url.pathname, body: init?.body ? JSON.parse(String(init.body)) : undefined }) if (url.pathname === '/api/v1/admin/market-check/settings') return jsonResponse(settings) + if (url.pathname === '/api/v1/pos/tiles') { + calls.push({ method: 'TILES', path: url.search, body: undefined }) + return tiles === null + ? new Response('404 page not found', { status: 404 }) + : jsonResponse({ categories: [], items: tiles, images: false }) + } if (url.pathname === '/api/v1/products') { return jsonResponse({ items: [1, 2, 3, 4, 5, 6, 7].map((n) => product(n)).concat([product(99, 'HW_NAILS')]), next_cursor: null, limit: 200 }) } @@ -93,7 +102,7 @@ describe('Market check screen', () => { expect(el.querySelector('[data-testid="run"]')).toBeNull() }) - it('shows the header, the store label, the badge and the first pick of lumber products', async () => { + it('shows the header, the store label, the badge and the first pick', async () => { const el = await open() expect(text(el)).toContain('Market check') expect(text(q(el, '[data-testid="store-label"]'))).toBe(SETTINGS.store_label) @@ -105,6 +114,34 @@ describe('Market check screen', () => { expect(chips).not.toContain('HW_NAILS') }) + it('prefers the yard top sellers in Lumber from the counter tiles, and says so', async () => { + tiles = [3, 1, 5].map((n) => ({ product_id: `p-${n}`, sku: `TOP_${n}`, description: `Top ${n}` })) + const el = await open() + expect(calls.find((c) => c.method === 'TILES')!.path).toBe('?category=Lumber&limit=6') + const chips = text(q(el, '[data-testid="chips"]')) + expect(chips).toContain('TOP_3') + expect(chips).toContain('TOP_5') + expect(chips).not.toContain('DL_SPF_1') + expect(text(el)).toContain('the yard top sellers in Lumber') + }) + + it('falls back to the first catalogue products, never by a SKU prefix, when the tiles are absent', async () => { + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://localhost') + if (url.pathname === '/api/v1/admin/market-check/settings') return jsonResponse(SETTINGS) + if (url.pathname === '/api/v1/products') { + return jsonResponse({ items: [1, 2, 3, 4, 5, 6, 7].map((n) => product(n, `HW_${n}`)), next_cursor: null, limit: 200 }) + } + return new Response('404 page not found', { status: 404 }) + })) + const el = await open() + const chips = text(q(el, '[data-testid="chips"]')) + expect(chips).toContain('HW_1') + expect(chips).toContain('HW_6') + expect(chips).not.toContain('HW_7') + expect(text(el)).toContain('the first products in the catalogue') + }) + it('removes a chip, adds from the search, and runs with the picked ids', async () => { const el = await open() q(el, '[aria-label="Remove DL_SPF_1"]').click() @@ -127,6 +164,7 @@ describe('Market check screen', () => { const url = new URL(String(input), 'http://localhost') if (url.pathname === '/api/v1/admin/market-check/settings') return jsonResponse(SETTINGS) if (url.pathname === '/api/v1/products') return jsonResponse({ items: [product(1)], next_cursor: null, limit: 200 }) + if (url.pathname === '/api/v1/pos/tiles') return new Response('404 page not found', { status: 404 }) return new Promise((resolve) => { release = resolve }) })) const el = await open() diff --git a/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts b/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts index bd7e3dd58..0c713fe15 100644 --- a/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts +++ b/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts @@ -6,6 +6,7 @@ import { customElement, state } from 'lit/decorators.js'; import { icon } from '../../../lib/icons'; import { Radar, Play, X, AlertTriangle } from 'lucide'; import { formatCents } from '../../../lib/utils'; +import { fetchWithAuth } from '../../../services/fetchClient'; import { apiErrorMessage, ApiError } from '../../../services/apiError'; import { ProductService, matchProducts } from '../../../services/product.service'; import { marketCheckService } from '../../../services/MarketCheckService'; @@ -14,7 +15,8 @@ import type { Product } from '../../../types/product'; /** The most products one check takes (the server's bound). */ const MAX_PICK = 12; -/** On first open the pick is the first few lumber products. */ +/** On first open the pick is the yard's top lumber sellers. */ +const API_URL = import.meta.env.VITE_API_URL || ''; const DEFAULT_PICK = 6; const COMPETITOR = 'Home Depot'; @@ -98,10 +100,10 @@ export class GableMarketCheck extends LitElement { // The product list answers no category and no search, so the pick and the // search both work over the catalogue the page loads once. this._catalog = await ProductService.listAllProducts(); - const lumber = this._catalog.filter((p) => p.sku.toUpperCase().startsWith('DL_')); - const first = (lumber.length >= 1 ? lumber : this._catalog).slice(0, DEFAULT_PICK); - this._pickSource = lumber.length >= 1 ? 'the first lumber products' : 'the first products in the catalogue'; - this._picked = first.map((p) => ({ id: p.id, sku: p.sku, description: p.description })); + const tiles = await this._topSellers(); + const chosen = tiles.length > 0 ? tiles : this._catalog.slice(0, DEFAULT_PICK); + this._pickSource = tiles.length > 0 ? 'the yard top sellers in Lumber' : 'the first products in the catalogue'; + this._picked = chosen.map((p) => ({ id: p.id, sku: p.sku, description: p.description })); } } catch (err) { this._loadError = apiErrorMessage(err, 'Could not load the Market check'); @@ -110,6 +112,21 @@ export class GableMarketCheck extends LitElement { } } + /** + * The yard's top sellers in Lumber from the counter's tiles read, when the route exists and answers some; + * any failure (a server without the route answers 404) is an empty list and the caller falls back. + */ + private async _topSellers(): Promise { + try { + const response = await fetchWithAuth(`${API_URL}/api/v1/pos/tiles?category=Lumber&limit=${DEFAULT_PICK}`); + if (!response.ok) return []; + const body = (await response.json()) as { items?: { product_id: string; sku: string; description: string }[] }; + return (body.items ?? []).slice(0, DEFAULT_PICK).map((t) => ({ id: t.product_id, sku: t.sku, description: t.description })); + } catch { + return []; + } + } + private _add(p: Product) { if (this._picked.length >= MAX_PICK || this._picked.some((x) => x.id === p.id)) return; this._picked = [...this._picked, { id: p.id, sku: p.sku, description: p.description }]; From eb7e6399259cb4449141df143c19bb9ce816e897 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 09:27:51 -0700 Subject: [PATCH 13/17] desk: the Market check fallback pick is fixed length dimensional lumber Without the counter tiles the pick is the six fixed length boards (all three board fields set, not random length) with the most available, then the first catalogue products when there are none. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- docs/parity/WALKS.md | 2 +- .../pages/admin/pricing/MarketCheck.test.ts | 33 +++++++++++++++++++ .../src/pages/admin/pricing/MarketCheck.ts | 28 ++++++++++++++-- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/docs/parity/WALKS.md b/docs/parity/WALKS.md index 61d0fdd4e..ef64a08fe 100644 --- a/docs/parity/WALKS.md +++ b/docs/parity/WALKS.md @@ -263,7 +263,7 @@ Needs the demo seed, a vault key (`PAYMENT_VAULT_KEY`), an FB Connect address an the webhook id, the store label ("Home Depot, Spokane WA: nearest store with live prices"), band 5 and floor 15, and Save. The card now shows the id as "Saved" with Replace and Clear, and never the value. `GET /api/v1/admin/market-check/settings` answers `webhook_id_saved: true` and no id. -2. Open Pricing, then Market check. The yard's six top lumber sellers are picked (the counter tiles; the first six catalogue products when that route is absent), and the page says which. Remove one chip, add another with the +2. Open Pricing, then Market check. The yard's six top lumber sellers are picked (the counter tiles; without that route the fixed length dimensional lumber with the most on hand, then the first catalogue products), and the page says which. Remove one chip, add another with the search, and press Run check. While it runs the rows are placeholders reading "Reading live prices through FB Connect". 3. Read the summary line and the table: each matched row shows our price, the Home Depot title and price with its confidence and unit note, the difference in percent and dollars, the position and the suggested price with its diff --git a/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts b/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts index 6f906b2c5..2ec2d8664 100644 --- a/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts +++ b/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts @@ -142,6 +142,39 @@ describe('Market check screen', () => { expect(text(el)).toContain('the first products in the catalogue') }) + it('falls back to the fixed length dimensional lumber with the most available, then to the first products', async () => { + const board = (n: number, avail: string, over: Record = {}) => ({ + ...product(n, `BRD_${n}`), board_thickness_in: '1.5000', board_width_in: '3.5000', board_length_ft: '8.0000', + random_length: false, available: avail, ...over, + }) + const items = [ + { ...product(1, 'TW_WIPE'), available: '900.0000' }, + board(2, '10.0000'), board(3, '500.0000'), board(4, '40.0000', { random_length: true }), + board(5, '300.0000'), board(6, '20.0000', { board_width_in: null }), board(7, '75.5000'), + board(8, '60.0000'), board(9, '5.0000'), board(10, '200.0000'), board(11, '9.0000'), + ] + let listing = items + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://localhost') + if (url.pathname === '/api/v1/admin/market-check/settings') return jsonResponse(SETTINGS) + if (url.pathname === '/api/v1/products') return jsonResponse({ items: listing, next_cursor: null, limit: 200 }) + return new Response('404 page not found', { status: 404 }) + })) + let el = await open() + const chips = text(q(el, '[data-testid="chips"]')) + for (const sku of ['BRD_3', 'BRD_5', 'BRD_10', 'BRD_7', 'BRD_8', 'BRD_2']) expect(chips).toContain(sku) + for (const sku of ['TW_WIPE', 'BRD_4', 'BRD_6', 'BRD_9', 'BRD_11']) expect(chips).not.toContain(sku) + expect(text(el)).toContain('the fixed length dimensional lumber with the most on hand') + // the chips are in order of most available + expect(chips.indexOf('BRD_3')).toBeLessThan(chips.indexOf('BRD_5')) + el.remove() + // none of it: the first products + listing = [{ ...product(1, 'TW_WIPE'), board_length_ft: null, random_length: false, available: '1.0000' }, product(2, 'TW_RAG')] as never + el = await open() + expect(text(q(el, '[data-testid="chips"]'))).toContain('TW_WIPE') + expect(text(el)).toContain('the first products in the catalogue') + }) + it('removes a chip, adds from the search, and runs with the picked ids', async () => { const el = await open() q(el, '[aria-label="Remove DL_SPF_1"]').click() diff --git a/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts b/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts index 0c713fe15..9b85392c0 100644 --- a/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts +++ b/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts @@ -26,6 +26,18 @@ interface Picked { description: string; } +/** + * The catalogue's fixed length dimensional lumber (all three board fields set, not random length), the + * most available first: the nearest read of "lumber" the product list carries (it has no category). + */ +export function fixedLengthLumber(catalog: Product[]): Picked[] { + return catalog + .filter((p) => p.board_thickness_in != null && p.board_width_in != null && p.board_length_ft != null && !p.random_length) + .sort((a, b) => Number.parseFloat(b.available) - Number.parseFloat(a.available)) + .slice(0, DEFAULT_PICK) + .map((p) => ({ id: p.id, sku: p.sku, description: p.description })); +} + /** "1 is below", "2 are below": the verb follows the count. */ function countPhrase(n: number, what: string): string { return `${n} ${n === 1 ? 'is' : 'are'} ${what}`; @@ -101,9 +113,19 @@ export class GableMarketCheck extends LitElement { // search both work over the catalogue the page loads once. this._catalog = await ProductService.listAllProducts(); const tiles = await this._topSellers(); - const chosen = tiles.length > 0 ? tiles : this._catalog.slice(0, DEFAULT_PICK); - this._pickSource = tiles.length > 0 ? 'the yard top sellers in Lumber' : 'the first products in the catalogue'; - this._picked = chosen.map((p) => ({ id: p.id, sku: p.sku, description: p.description })); + const boards = fixedLengthLumber(this._catalog); + let chosen: Picked[]; + if (tiles.length > 0) { + chosen = tiles; + this._pickSource = 'the yard top sellers in Lumber'; + } else if (boards.length > 0) { + chosen = boards; + this._pickSource = 'the fixed length dimensional lumber with the most on hand'; + } else { + chosen = this._catalog.slice(0, DEFAULT_PICK).map((p) => ({ id: p.id, sku: p.sku, description: p.description })); + this._pickSource = 'the first products in the catalogue'; + } + this._picked = chosen; } } catch (err) { this._loadError = apiErrorMessage(err, 'Could not load the Market check'); From 7be45f670bcd631c2ac84077f11fdb8fdd332254 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 09:50:12 -0700 Subject: [PATCH 14/17] desk: the Market check fallback pick is 2x4 to 2x12 dimensional lumber, 8 to 16 feet Engineered wood (LVL) has fixed length and large stock but is not dimensional lumber: nominal thickness 2, width 4 to 12, length 8 to 16 feet, not random length, then the six with the most available. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- docs/parity/WALKS.md | 2 +- .../pages/admin/pricing/MarketCheck.test.ts | 23 +++++++++++++------ .../src/pages/admin/pricing/MarketCheck.ts | 19 ++++++++++----- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/docs/parity/WALKS.md b/docs/parity/WALKS.md index ef64a08fe..7836544e2 100644 --- a/docs/parity/WALKS.md +++ b/docs/parity/WALKS.md @@ -263,7 +263,7 @@ Needs the demo seed, a vault key (`PAYMENT_VAULT_KEY`), an FB Connect address an the webhook id, the store label ("Home Depot, Spokane WA: nearest store with live prices"), band 5 and floor 15, and Save. The card now shows the id as "Saved" with Replace and Clear, and never the value. `GET /api/v1/admin/market-check/settings` answers `webhook_id_saved: true` and no id. -2. Open Pricing, then Market check. The yard's six top lumber sellers are picked (the counter tiles; without that route the fixed length dimensional lumber with the most on hand, then the first catalogue products), and the page says which. Remove one chip, add another with the +2. Open Pricing, then Market check. The yard's six top lumber sellers are picked (the counter tiles; without that route the dimensional lumber (2x4 to 2x12, 8 to 16 feet) with the most on hand, then the first catalogue products), and the page says which. Remove one chip, add another with the search, and press Run check. While it runs the rows are placeholders reading "Reading live prices through FB Connect". 3. Read the summary line and the table: each matched row shows our price, the Home Depot title and price with its confidence and unit note, the difference in percent and dollars, the position and the suggested price with its diff --git a/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts b/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts index 2ec2d8664..80061a9fc 100644 --- a/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts +++ b/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts @@ -142,16 +142,23 @@ describe('Market check screen', () => { expect(text(el)).toContain('the first products in the catalogue') }) - it('falls back to the fixed length dimensional lumber with the most available, then to the first products', async () => { + it('falls back to dimensional lumber (2x4 to 2x12, 8 to 16 feet) with the most on hand, then to the first products', async () => { const board = (n: number, avail: string, over: Record = {}) => ({ - ...product(n, `BRD_${n}`), board_thickness_in: '1.5000', board_width_in: '3.5000', board_length_ft: '8.0000', + ...product(n, `BRD_${n}`), board_thickness_in: '2', board_width_in: '4', board_length_ft: '8', random_length: false, available: avail, ...over, }) const items = [ { ...product(1, 'TW_WIPE'), available: '900.0000' }, - board(2, '10.0000'), board(3, '500.0000'), board(4, '40.0000', { random_length: true }), - board(5, '300.0000'), board(6, '20.0000', { board_width_in: null }), board(7, '75.5000'), - board(8, '60.0000'), board(9, '5.0000'), board(10, '200.0000'), board(11, '9.0000'), + // engineered wood: fixed length, thousands on hand, but not 2x stock + { ...product(30, 'EW_LVL_W18000_L32'), board_thickness_in: '1.75', board_width_in: '18', board_length_ft: '32', random_length: false, available: '5000.0000' }, + board(2, '10.0000'), board(3, '500.0000', { board_width_in: '12', board_length_ft: '16' }), + board(4, '40.0000', { random_length: true }), + board(5, '300.0000', { board_width_in: '6.0000', board_length_ft: '10.0000', board_thickness_in: '2.0000' }), + board(6, '20.0000', { board_width_in: null }), board(7, '75.5000'), board(8, '60.0000'), + board(9, '5.0000'), board(10, '200.0000'), board(11, '9.0000'), + board(12, '4000.0000', { board_thickness_in: '4' }), board(13, '4000.0000', { board_width_in: '14' }), + board(14, '4000.0000', { board_length_ft: '20' }), board(15, '4000.0000', { board_length_ft: '6' }), + board(16, '4000.0000', { board_width_in: '2' }), ] let listing = items vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { @@ -163,8 +170,10 @@ describe('Market check screen', () => { let el = await open() const chips = text(q(el, '[data-testid="chips"]')) for (const sku of ['BRD_3', 'BRD_5', 'BRD_10', 'BRD_7', 'BRD_8', 'BRD_2']) expect(chips).toContain(sku) - for (const sku of ['TW_WIPE', 'BRD_4', 'BRD_6', 'BRD_9', 'BRD_11']) expect(chips).not.toContain(sku) - expect(text(el)).toContain('the fixed length dimensional lumber with the most on hand') + for (const sku of ['TW_WIPE', 'EW_LVL', 'BRD_4', 'BRD_6', 'BRD_9', 'BRD_11', 'BRD_12', 'BRD_13', 'BRD_14', 'BRD_15', 'BRD_16']) { + expect(chips).not.toContain(sku) + } + expect(text(el)).toContain('the dimensional lumber (2x4 to 2x12, 8 to 16 feet) with the most on hand') // the chips are in order of most available expect(chips.indexOf('BRD_3')).toBeLessThan(chips.indexOf('BRD_5')) el.remove() diff --git a/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts b/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts index 9b85392c0..3449a3653 100644 --- a/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts +++ b/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts @@ -27,12 +27,19 @@ interface Picked { } /** - * The catalogue's fixed length dimensional lumber (all three board fields set, not random length), the - * most available first: the nearest read of "lumber" the product list carries (it has no category). + * The catalogue's dimensional lumber by the trade's definition from the board fields: nominal thickness 2, + * width 4 to 12 inches, length 8 to 16 feet, not random length (so 2x4 to 2x12, 8 to 16 feet; engineered + * wood such as LVL is not). The most available first. The product list carries no category. */ -export function fixedLengthLumber(catalog: Product[]): Picked[] { +export function dimensionalLumber(catalog: Product[]): Picked[] { + const num = (v: unknown) => (v == null ? NaN : Number.parseFloat(String(v))); return catalog - .filter((p) => p.board_thickness_in != null && p.board_width_in != null && p.board_length_ft != null && !p.random_length) + .filter((p) => { + const t = num(p.board_thickness_in); + const w = num(p.board_width_in); + const l = num(p.board_length_ft); + return !p.random_length && t === 2 && w >= 4 && w <= 12 && l >= 8 && l <= 16; + }) .sort((a, b) => Number.parseFloat(b.available) - Number.parseFloat(a.available)) .slice(0, DEFAULT_PICK) .map((p) => ({ id: p.id, sku: p.sku, description: p.description })); @@ -113,14 +120,14 @@ export class GableMarketCheck extends LitElement { // search both work over the catalogue the page loads once. this._catalog = await ProductService.listAllProducts(); const tiles = await this._topSellers(); - const boards = fixedLengthLumber(this._catalog); + const boards = dimensionalLumber(this._catalog); let chosen: Picked[]; if (tiles.length > 0) { chosen = tiles; this._pickSource = 'the yard top sellers in Lumber'; } else if (boards.length > 0) { chosen = boards; - this._pickSource = 'the fixed length dimensional lumber with the most on hand'; + this._pickSource = 'the dimensional lumber (2x4 to 2x12, 8 to 16 feet) with the most on hand'; } else { chosen = this._catalog.slice(0, DEFAULT_PICK).map((p) => ({ id: p.id, sku: p.sku, description: p.description })); this._pickSource = 'the first products in the catalogue'; From 9ad89089c02bba8b9a41b0d05a3bc19f85b909a7 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 10:19:31 -0700 Subject: [PATCH 15/17] marketcheck: failing tests for fix round 1 (deadline, stored settings, dial guard, changed address, small ones) Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/marketcheck/compute_test.go | 2 +- core/internal/marketcheck/export_test.go | 8 + core/internal/marketcheck/fix1_test.go | 330 ++++++++++++++++++++++ 3 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 core/internal/marketcheck/export_test.go create mode 100644 core/internal/marketcheck/fix1_test.go diff --git a/core/internal/marketcheck/compute_test.go b/core/internal/marketcheck/compute_test.go index e3327ba54..2a222ca81 100644 --- a/core/internal/marketcheck/compute_test.go +++ b/core/internal/marketcheck/compute_test.go @@ -26,7 +26,7 @@ func TestSuggest(t *testing.T) { {"above target over the floor", 1000, 600, 500, "above", 630, "lower_to_band", 66.7, true}, {"above target under the floor", 1000, 550, 500, "above", 589, "held_at_floor", 81.8, true}, {"below raises to the band", 700, 1000, 500, "below", 950, "raise_to_band", -30.0, true}, - {"below raise still max with floor", 520, 560, 500, "below", 589, "raise_to_band", -7.1, true}, + {"below raise held at the floor", 520, 560, 500, "below", 589, "held_at_floor", -7.1, true}, {"in line keeps", 1000, 1000, 500, "in_line", 1000, "keep", 0.0, true}, {"in line edge of band is in line", 1050, 1000, 500, "in_line", 1050, "keep", 5.0, true}, {"in line under the floor takes the floor", 550, 550, 500, "in_line", 589, "held_at_floor", 0.0, true}, diff --git a/core/internal/marketcheck/export_test.go b/core/internal/marketcheck/export_test.go new file mode 100644 index 000000000..b9e19ca94 --- /dev/null +++ b/core/internal/marketcheck/export_test.go @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package marketcheck + +// RefusedDial and AllowPrivateFor expose the dial guard's decisions to the tests. +func RefusedDial(addr string, allowPrivate bool) error { return checkDialAddress(addr, allowPrivate) } +func AllowPrivateFor(host string) bool { return allowPrivateFor(host) } diff --git a/core/internal/marketcheck/fix1_test.go b/core/internal/marketcheck/fix1_test.go new file mode 100644 index 000000000..2cb0fb8fa --- /dev/null +++ b/core/internal/marketcheck/fix1_test.go @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package marketcheck_test + +// Fix round 1 of PR 145: the run's write deadline, the stored settings +// re-validated at run time, the dial guard, the id following a changed +// address, and the small ones. + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gablelbm/gable/internal/marketcheck" +) + +func TestHandler_RunSurvivesAShortServerWriteTimeout(t *testing.T) { + a := product("S1", 500, 100) + stub := newFlow(t, func(w http.ResponseWriter, r *http.Request, body string) { + time.Sleep(700 * time.Millisecond) + matchAll(4)(w, r, body) + }) + svc, _, _ := configured(t, stub, catalogOf(a), nil) + svc.WithBatchTimeout(5 * time.Second) + mux := http.NewServeMux() + marketcheck.NewHandler(svc).RegisterRoutes(mux, nil, nil) + srv := httptest.NewUnstartedServer(mux) + srv.Config.WriteTimeout = 300 * time.Millisecond // serve's is 15 seconds; the run outlives it + srv.Start() + defer srv.Close() + body := fmt.Sprintf(`{"product_ids":[%q]}`, a.ID.String()) + resp, err := http.Post(srv.URL+"/api/v1/pricing/market-check", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("the response was cut: %v", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 || !strings.Contains(string(raw), `"status":"matched"`) { + t.Fatalf("%d %s", resp.StatusCode, raw) + } +} + +func TestHandler_SecondRunOfTheSameCallerIsRefusedWhileOneRuns(t *testing.T) { + a := product("S1", 500, 100) + release := make(chan struct{}) + started := make(chan struct{}, 4) + stub := newFlow(t, func(w http.ResponseWriter, r *http.Request, body string) { + started <- struct{}{} + <-release + matchAll(4)(w, r, body) + }) + svc, _, _ := configured(t, stub, catalogOf(a), nil) + mux := http.NewServeMux() + marketcheck.NewHandler(svc).RegisterRoutes(mux, nil, nil) + srv := httptest.NewServer(mux) + defer srv.Close() + body := fmt.Sprintf(`{"product_ids":[%q]}`, a.ID.String()) + first := make(chan int, 1) + go func() { + resp, err := http.Post(srv.URL+"/api/v1/pricing/market-check", "application/json", strings.NewReader(body)) + if err != nil { + first <- -1 + return + } + resp.Body.Close() + first <- resp.StatusCode + }() + <-started + resp, err := http.Post(srv.URL+"/api/v1/pricing/market-check", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 429 || !strings.Contains(string(raw), "already") { + t.Fatalf("second run: %d %s", resp.StatusCode, raw) + } + close(release) + if code := <-first; code != 200 { + t.Fatalf("first run: %d", code) + } + // and once it is done a new run is accepted + resp, err = http.Post(srv.URL+"/api/v1/pricing/market-check", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("after: %d", resp.StatusCode) + } +} + +// rawStore returns a service whose stored rows were written straight into +// system_settings (a path other than the PUT), sealed with the fake vault. +func rawStore(t *testing.T, rows map[string]string) *marketcheck.Service { + t.Helper() + st := newMemStore() + for k, v := range rows { + st.rows[k] = v + } + return marketcheck.NewService(st, catalogOf()).WithVault(fakeVault{present: true}).WithTxRunner(passTx{}). + WithBatchTimeout(time.Second) +} + +func sealed(v string) string { s, _ := fakeVault{}.Seal(v); return s } + +func TestRun_StoredSettingsAreRevalidated(t *testing.T) { + cases := map[string]map[string]string{ + "metadata address over http": {"market_check.connect_url": "http://169.254.169.254", "market_check.webhook_id": sealed(testHook)}, + "address with user info": {"market_check.connect_url": "https://u:p@connect.example.test", "market_check.webhook_id": sealed(testHook)}, + "address with a path": {"market_check.connect_url": "https://connect.example.test/x?", "market_check.webhook_id": sealed(testHook)}, + "id not sealed": {"market_check.connect_url": "https://connect.example.test", "market_check.webhook_id": "x/../../other?"}, + "sealed id off pattern": {"market_check.connect_url": "https://connect.example.test", "market_check.webhook_id": sealed("x/../../other?")}, + } + for name, rows := range cases { + t.Run(name, func(t *testing.T) { + svc := rawStore(t, rows) + _, err := svc.Run(context.Background(), ids(product("S1", 500, 100))) + if err != marketcheck.ErrNotConfigured { + t.Fatalf("want ErrNotConfigured, got %v", err) + } + s, _ := svc.Settings(context.Background()) + if s.Configured { + t.Fatalf("settings still read configured: %+v", s) + } + }) + } +} + +func TestDialGuard(t *testing.T) { + refused := []string{ + "169.254.169.254:80", "169.254.10.1:443", "[fe80::1]:443", "0.0.0.0:80", "[::]:80", + "224.0.0.1:80", "[ff02::1]:80", "[fd00:ec2::254]:80", + } + for _, a := range refused { + for _, allowPrivate := range []bool{false, true} { + if err := marketcheck.RefusedDial(a, allowPrivate); err == nil { + t.Errorf("%s allowed (allowPrivate %v)", a, allowPrivate) + } + } + } + private := []string{"127.0.0.1:80", "[::1]:80", "10.1.2.3:443", "192.168.1.5:443", "172.16.0.9:443", "100.64.1.1:443", "[fd12::1]:443"} + for _, a := range private { + if err := marketcheck.RefusedDial(a, false); err == nil { + t.Errorf("%s allowed to a public name's address", a) + } + if err := marketcheck.RefusedDial(a, true); err != nil { + t.Errorf("%s refused though the stored address is itself private: %v", a, err) + } + } + if err := marketcheck.RefusedDial("93.184.216.34:443", false); err != nil { + t.Errorf("a public address refused: %v", err) + } + for host, want := range map[string]bool{"127.0.0.1": true, "localhost": true, "::1": true, "10.0.0.5": true, + "100.64.3.3": true, "connect.example.test": false, "93.184.216.34": false} { + if got := marketcheck.AllowPrivateFor(host); got != want { + t.Errorf("AllowPrivateFor(%s) = %v", host, got) + } + } +} + +func TestRun_LinkLocalAddressIsNeverDialled(t *testing.T) { + a := product("S1", 500, 100) + lg, cap := newLogger() + st := newMemStore() + svc := marketcheck.NewService(st, catalogOf(a)).WithVault(fakeVault{present: true}).WithTxRunner(passTx{}). + WithLogger(lg).WithBatchTimeout(3 * time.Second) + // https passes the address validation; the address is link local. + if _, err := svc.SaveSettings(context.Background(), marketcheck.SettingsInput{ + ConnectURL: ptr("https://169.254.169.254"), WebhookID: ptr(testHook)}); err != nil { + t.Fatal(err) + } + start := time.Now() + res, err := svc.Run(context.Background(), ids(a)) + if err != nil { + t.Fatal(err) + } + if r := res.Rows[0]; r.Status != "error" || r.Note != "FB Connect did not answer" { + t.Fatalf("%+v", r) + } + if time.Since(start) > 2*time.Second { + t.Fatalf("the refusal took %v: it was dialled until timeout", time.Since(start)) + } + if !strings.Contains(cap.String(), "blocked_address") || strings.Contains(cap.String(), testHook) { + t.Fatalf("log: %s", cap.String()) + } +} + +func TestSettings_ChangedAddressClearsTheSavedID(t *testing.T) { + svc, st := newSvc(true) + ctx := context.Background() + if _, err := svc.SaveSettings(ctx, marketcheck.SettingsInput{ConnectURL: ptr("https://a.example.test"), WebhookID: ptr(testHook)}); err != nil { + t.Fatal(err) + } + // the same address again keeps the id + got, err := svc.SaveSettings(ctx, marketcheck.SettingsInput{ConnectURL: ptr("https://a.example.test/"), FlowName: ptr("x")}) + if err != nil || !got.WebhookIDSaved { + t.Fatalf("same address: %+v %v", got, err) + } + // a new address alone clears it, in words + got, err = svc.SaveSettings(ctx, marketcheck.SettingsInput{ConnectURL: ptr("https://b.example.test")}) + if err != nil { + t.Fatal(err) + } + if got.WebhookIDSaved || got.Configured || got.ConnectURL != "https://b.example.test" || + got.Notice != "Enter the webhook id again for the new address" { + t.Fatalf("changed address: %+v", got) + } + if _, ok := st.rows["market_check.webhook_id"]; ok { + t.Fatal("the old id is still stored") + } + // a new address with a new id keeps the new id + if _, err := svc.SaveSettings(ctx, marketcheck.SettingsInput{ConnectURL: ptr("https://c.example.test"), WebhookID: ptr("test-hook-0002")}); err != nil { + t.Fatal(err) + } + got, _ = svc.Settings(ctx) + if !got.WebhookIDSaved || !got.Configured || got.Notice != "" { + t.Fatalf("new address and id: %+v", got) + } +} + +func TestSettings_UnreadableIDIsNotConfiguredInWords(t *testing.T) { + st := newMemStore() + good := marketcheck.NewService(st, catalogOf()).WithVault(fakeVault{present: true}).WithTxRunner(passTx{}) + if _, err := good.SaveSettings(context.Background(), marketcheck.SettingsInput{ + ConnectURL: ptr("https://a.example.test"), WebhookID: ptr(testHook)}); err != nil { + t.Fatal(err) + } + noKey := marketcheck.NewService(st, catalogOf()).WithVault(fakeVault{present: false}) + got, err := noKey.Settings(context.Background()) + if err != nil { + t.Fatal(err) + } + if got.Configured || !got.WebhookIDSaved || got.Notice != "The saved webhook id cannot be read; enter it again" { + t.Fatalf("%+v", got) + } +} + +func TestRun_LongLinkIsDropped(t *testing.T) { + a := product("S1", 500, 100) + long := "https://www.homedepot.com/p/" + strings.Repeat("a", 2100) + stub := newFlow(t, func(w http.ResponseWriter, r *http.Request, body string) { + _, _ = fmt.Fprintf(w, `{"store":null,"rows":[{"sku":"S1","status":"matched","match":{"title":"t","theirPrice":4,"listedPrice":4,"link":%q,"confidence":50,"normNote":""}}]}`, long) + }) + svc, _, _ := configured(t, stub, catalogOf(a), nil) + res, err := svc.Run(context.Background(), ids(a)) + if err != nil { + t.Fatal(err) + } + if r := res.Rows[0]; r.Status != "matched" || r.Competitor.Link != "" { + t.Fatalf("%+v", r.Competitor) + } +} + +func TestRun_BandPercentIsFromTheParsedValue(t *testing.T) { + a := product("S1", 500, 100) + stub := newFlow(t, matchAll(4)) + svc, st, _ := configured(t, stub, catalogOf(a), nil) + st.rows["market_check.band_pct"] = "05" // accepted by the parser, invalid as a JSON number + st.rows["market_check.margin_floor_pct"] = "15.50" + res, err := svc.Run(context.Background(), ids(a)) + if err != nil { + t.Fatal(err) + } + if res.Rows[0].Status != "matched" { + t.Fatalf("%+v", res.Rows[0]) + } + var sent struct { + BandPercent json.Number `json:"bandPercent"` + } + if err := json.Unmarshal([]byte(stub.bodies[0]), &sent); err != nil || sent.BandPercent.String() != "5" { + t.Fatalf("sent %q: %v", stub.bodies[0], err) + } +} + +func TestRun_NeverMoreThanFourBatchesAtOnce(t *testing.T) { + var ps []marketcheck.Product + for i := 0; i < 12; i++ { + ps = append(ps, product(fmt.Sprintf("SKU_%d", i), 500, 100)) + } + var cur, peak int32 + var mu sync.Mutex + stub := newFlow(t, func(w http.ResponseWriter, r *http.Request, body string) { + n := atomic.AddInt32(&cur, 1) + mu.Lock() + if n > peak { + peak = n + } + mu.Unlock() + time.Sleep(150 * time.Millisecond) + atomic.AddInt32(&cur, -1) + matchAll(4)(w, r, body) + }) + svc, _, _ := configured(t, stub, catalogOf(ps...), nil) + res, err := svc.Run(context.Background(), ids(ps...)) + if err != nil { + t.Fatal(err) + } + for _, r := range res.Rows { + if r.Status != "matched" { + t.Fatalf("%+v", r) + } + } + if len(stub.bodies) != 4 || peak > 4 || peak < 2 { + t.Fatalf("batches %d, peak concurrent %d", len(stub.bodies), peak) + } +} + +func TestRun_BelowRowHeldAtTheFloorSaysSo(t *testing.T) { + a := product("S1", 520, 500) // floor 589; theirs 560, ours 520 is below the band + stub := newFlow(t, matchAll(5.60)) + svc, _, _ := configured(t, stub, catalogOf(a), nil) + res, err := svc.Run(context.Background(), ids(a)) + if err != nil { + t.Fatal(err) + } + r := res.Rows[0] + if r.Position != "below" || r.SuggestedPriceCents == nil || *r.SuggestedPriceCents != 589 || r.Reason != "held_at_floor" { + t.Fatalf("%+v", r) + } +} From 3b3d72591acc7c10f10d08ee6b7d666b4019bdd4 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 10:21:06 -0700 Subject: [PATCH 16/17] marketcheck: write deadline for the run, stored settings re-validated, dial guard, a changed address clears the id, the small ones The server's 15 second write timeout cut a run that waited longer on the flow: the run now sets its own response deadline past one batch. The stored address and id are re-validated at run time, the client refuses link local, unspecified and multicast addresses (and private ones for a public name) at dial time, a changed address clears the saved id, one run at a time per caller, and the price is the counter's own expression. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/app/serve/serve.go | 14 +++- .../app/serve/wire_key_branch_pin_test.go | 3 + .../app/serve/wire_marketcheck_test.go | 62 ++++++++++++++ core/internal/marketcheck/compute.go | 2 +- core/internal/marketcheck/dial.go | 81 +++++++++++++++++++ core/internal/marketcheck/handler.go | 36 ++++++++- core/internal/marketcheck/postgres.go | 15 ++-- core/internal/marketcheck/run.go | 34 +++++--- core/internal/marketcheck/service.go | 9 +-- core/internal/marketcheck/settings.go | 59 +++++++++++++- core/internal/pos/price.go | 10 +++ core/internal/pos/repository.go | 4 +- 12 files changed, 294 insertions(+), 35 deletions(-) create mode 100644 core/internal/app/serve/wire_marketcheck_test.go create mode 100644 core/internal/marketcheck/dial.go create mode 100644 core/internal/pos/price.go diff --git a/core/internal/app/serve/serve.go b/core/internal/app/serve/serve.go index 77258db72..a5d0ebbf3 100644 --- a/core/internal/app/serve/serve.go +++ b/core/internal/app/serve/serve.go @@ -408,9 +408,7 @@ func Run() { marketCheckSvc := marketcheck.NewService(marketcheck.NewPostgresStore(db), marketcheck.NewPostgresCatalog(db)). WithVault(aiVault).WithTxRunner(db).WithLogger(logger). WithAudit(marketcheck.AuditRecorder{L: auditLog}) - marketcheck.NewHandler(marketCheckSvc).RegisterRoutes(mux, - middleware.RequireRole("admin", "owner"), - pricingRulesCustomerWall(wall.keyWall, db, "admin", "owner")) + registerMarketCheck(mux, marketCheckSvc, wall.keyWall, db) // Category Pricing Engine (feature-flagged) if strings.EqualFold(os.Getenv("CATEGORY_PRICING_ENABLED"), "true") { @@ -1393,3 +1391,13 @@ func rowOrNone(r links.Row, ok bool) links.Row { } return r } + +// registerMarketCheck mounts the Market check's three routes with their +// walls: the settings behind the admin and owner role guard, the run behind +// the pricing rules' wall. One function, so serve and the wire test mount the +// same guards. +func registerMarketCheck(mux *http.ServeMux, svc *marketcheck.Service, keyWall *middleware.KeyBranchWall, db *database.DB) { + marketcheck.NewHandler(svc).RegisterRoutes(mux, + middleware.RequireRole("admin", "owner"), + pricingRulesCustomerWall(keyWall, db, "admin", "owner")) +} diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index dda6a485a..7acdbcf4d 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -49,6 +49,7 @@ import ( "github.com/gablelbm/gable/internal/invoice" "github.com/gablelbm/gable/internal/links" "github.com/gablelbm/gable/internal/location" + "github.com/gablelbm/gable/internal/marketcheck" "github.com/gablelbm/gable/internal/matching" "github.com/gablelbm/gable/internal/millwork" "github.com/gablelbm/gable/internal/notification" @@ -431,6 +432,8 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { RegisterRoutes(mux, taxCustomerWall(wall, db, "admin", "owner", "finance")) pricing.NewHandler(pricing.NewService(pricing.NewRepository(db)).WithTxRunner(db), customerSvc, productSvc). RegisterRoutes(mux, pricingRulesCustomerWall(wall.keyWall, db, "admin", "owner")) + registerMarketCheck(mux, marketcheck.NewService(marketcheck.NewPostgresStore(db), marketcheck.NewPostgresCatalog(db)). + WithTxRunner(db), wall.keyWall, db) pricing.NewCategoryHandler( pricing.NewCategoryPricingService(pricing.NewCategoryRepository(db)).WithTxRunner(db). WithOutbox(outbox.NewWriter(db, "")). diff --git a/core/internal/app/serve/wire_marketcheck_test.go b/core/internal/app/serve/wire_marketcheck_test.go new file mode 100644 index 000000000..c191ec5f8 --- /dev/null +++ b/core/internal/app/serve/wire_marketcheck_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +import ( + "net/http" + "testing" + + "github.com/google/uuid" +) + +// TestMarketCheck_RealWalls drives the three Market check routes through the +// composed chain (the machine key core, the allowlist gate, the role guards +// registerMarketCheck mounts): roles, a branch bound key, and a key without +// the settings scope. +func TestMarketCheck_RealWalls(t *testing.T) { + f := newKeyPinFixture(t) + const settings = "/api/v1/admin/market-check/settings" + const run = "/api/v1/pricing/market-check" + runBody := `{"product_ids":["` + uuid.NewString() + `"]}` + + // Users: only admin and owner. + for _, role := range []string{"cashier", "sales", "finance", "warehouse"} { + for _, rt := range []struct{ method, path, body string }{ + {"GET", settings, ""}, {"PUT", settings, `{"flow_name":"x"}`}, {"POST", run, runBody}, + } { + if status, buf := f.userAs(t, role, rt.method, rt.path, rt.body, nil); status != http.StatusForbidden { + t.Errorf("%s %s %s: %d %s, want 403", role, rt.method, rt.path, status, buf) + } + } + } + for _, role := range []string{"admin", "owner"} { + if status, buf := f.userAs(t, role, "GET", settings, "", nil); status != http.StatusOK { + t.Errorf("%s GET settings: %d %s", role, status, buf) + } + // Not configured: the run is reached and answers in words, not a wall. + if status, buf := f.userAs(t, role, "POST", run, runBody, nil); status != http.StatusConflict { + t.Errorf("%s POST run: %d %s, want 409 not_configured", role, status, buf) + } + } + + // A branch bound key holding admin:settings is refused the write and the run. + if status, buf := f.key(t, f.bound, "PUT", settings, `{"flow_name":"x"}`, nil); status != http.StatusForbidden { + t.Errorf("bound key PUT settings: %d %s, want 403", status, buf) + } + if status, buf := f.key(t, f.bound, "POST", run, runBody, nil); status != http.StatusForbidden { + t.Errorf("bound key POST run: %d %s, want 403", status, buf) + } + // An unbound key with admin:settings reads the settings (a stated limit). + if status, buf := f.key(t, f.unbound, "GET", settings, "", nil); status != http.StatusOK { + t.Errorf("unbound key GET settings: %d %s", status, buf) + } + + // An unbound key without admin:settings is refused both settings routes. + narrow := f.mintWithScopes(t, "market check narrow", []string{"pricing:read", "pricing:write"}, nil) + for _, rt := range []struct{ method, body string }{{"GET", ""}, {"PUT", `{"flow_name":"x"}`}} { + if status, buf := f.key(t, narrow, rt.method, settings, rt.body, nil); status != http.StatusForbidden { + t.Errorf("narrow key %s settings: %d %s, want 403", rt.method, status, buf) + } + } +} diff --git a/core/internal/marketcheck/compute.go b/core/internal/marketcheck/compute.go index b40cd4cbc..5fadb1c76 100644 --- a/core/internal/marketcheck/compute.go +++ b/core/internal/marketcheck/compute.go @@ -75,7 +75,7 @@ func Suggest(ours, theirs, cost, bandBP, floorBP int64) Suggestion { target := theirs * (10000 - bandBP) / 10000 s.SuggestedCents, s.Reason = target, ReasonRaiseToBand if target < floor { - s.SuggestedCents = floor + s.SuggestedCents, s.Reason = floor, ReasonHeldAtFloor } default: s.SuggestedCents, s.Reason = ours, ReasonKeep diff --git a/core/internal/marketcheck/dial.go b/core/internal/marketcheck/dial.go new file mode 100644 index 000000000..d4fe9cad4 --- /dev/null +++ b/core/internal/marketcheck/dial.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package marketcheck + +import ( + "errors" + "net" + "net/http" + "syscall" + "time" +) + +// errDialRefused marks a connection the guard refused; callBatch logs it as +// blocked_address and never keeps the transport's text (it holds the address). +var errDialRefused = errors.New("marketcheck: address refused") + +var ( + cgnat = &net.IPNet{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)} + awsIPv6Meta = net.ParseIP("fd00:ec2::254") +) + +// isPrivateOrLoopback reports the ranges a self hosted FB Connect lives in. +func isPrivateOrLoopback(ip net.IP) bool { + return ip.IsLoopback() || ip.IsPrivate() || cgnat.Contains(ip) +} + +// allowPrivateFor decides, from the stored address's own host, whether +// private and loopback ranges may be dialled: only when that host is itself +// a loopback or private literal (or localhost). A public name that resolves +// inward is refused, which is also what defeats DNS rebinding. +func allowPrivateFor(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && isPrivateOrLoopback(ip) +} + +// checkDialAddress judges one resolved "ip:port". Link local (the cloud +// metadata address among them), unspecified and multicast addresses are +// refused always; loopback, private and CGNAT only when allowPrivate is false. +func checkDialAddress(addr string, allowPrivate bool) error { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return errDialRefused + } + ip := net.ParseIP(host) + if ip == nil { + return errDialRefused + } + if ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsInterfaceLocalMulticast() || ip.Equal(awsIPv6Meta) { + return errDialRefused + } + if !allowPrivate && isPrivateOrLoopback(ip) { + return errDialRefused + } + return nil +} + +// newGuardedClient is the run's HTTP client: no environment proxy, a dial +// time guard (after resolution, per connection), and no redirects followed. +func newGuardedClient(allowPrivate bool) *http.Client { + dialer := &net.Dialer{ + Timeout: 10 * time.Second, + Control: func(_, address string, _ syscall.RawConn) error { + return checkDialAddress(address, allowPrivate) + }, + } + return &http.Client{ + Transport: &http.Transport{ + Proxy: nil, + DialContext: dialer.DialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 0, + DisableKeepAlives: true, + }, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } +} diff --git a/core/internal/marketcheck/handler.go b/core/internal/marketcheck/handler.go index 882f73c7f..289dcb225 100644 --- a/core/internal/marketcheck/handler.go +++ b/core/internal/marketcheck/handler.go @@ -6,16 +6,31 @@ package marketcheck import ( "encoding/json" "net/http" + "sync" + "time" "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" ) // Handler serves the settings and the run. -type Handler struct{ svc *Service } +type Handler struct { + svc *Service + mu sync.Mutex + inflight map[string]bool // callers with a run in progress +} // NewHandler builds the handler. -func NewHandler(s *Service) *Handler { return &Handler{svc: s} } +func NewHandler(s *Service) *Handler { return &Handler{svc: s, inflight: map[string]bool{}} } + +// callerKey names the caller for the one run at a time rule. +func callerKey(r *http.Request) string { + if c := middleware.ClaimsFromContext(r.Context()); c != nil && c.Subject != "" { + return "user:" + c.Subject + } + return "anonymous" +} // RegisterRoutes mounts the three routes. settingsGuard walls the Tech Admin // settings; runGuard walls the run (the pricing rules' walls). @@ -91,6 +106,23 @@ func (h *Handler) run(w http.ResponseWriter, r *http.Request) { } ids = append(ids, id) } + // The run waits on the flow up to one batch timeout, longer than the + // server's own write timeout: this one response gets a deadline past it. + _ = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(h.svc.BatchTimeout() + 10*time.Second)) + key := callerKey(r) + h.mu.Lock() + if h.inflight[key] { + h.mu.Unlock() + httpx.WriteError(w, r, httpx.RateLimited("A market check is already running for you; wait for it to finish")) + return + } + h.inflight[key] = true + h.mu.Unlock() + defer func() { + h.mu.Lock() + delete(h.inflight, key) + h.mu.Unlock() + }() out, err := h.svc.Run(r.Context(), ids) if err != nil { httpx.WriteError(w, r, err) diff --git a/core/internal/marketcheck/postgres.go b/core/internal/marketcheck/postgres.go index 18e9d5324..4c37fd273 100644 --- a/core/internal/marketcheck/postgres.go +++ b/core/internal/marketcheck/postgres.go @@ -8,6 +8,7 @@ import ( "errors" "strings" + "github.com/gablelbm/gable/internal/pos" "github.com/gablelbm/gable/pkg/audit" "github.com/gablelbm/gable/pkg/database" "github.com/gablelbm/gable/pkg/middleware" @@ -47,8 +48,8 @@ func (p *PostgresStore) Delete(ctx context.Context, key string) error { } // PostgresCatalog reads the yard and the products. Our price is the product's -// base price in cents, exactly the figure the counter's search answers as -// unit_price_cents (pos SearchProducts), and our cost its average unit cost. +// walk in price in cents through the counter's own expression +// (pos.WalkInPriceCentsSQL), and our cost its average unit cost. type PostgresCatalog struct{ db *database.DB } // NewPostgresCatalog builds the catalogue reader. @@ -85,11 +86,11 @@ func (c *PostgresCatalog) Yard(ctx context.Context) (Yard, error) { // Products reads the picked products. func (c *PostgresCatalog) Products(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]Product, error) { rows, err := c.db.GetExecutor(ctx).Query(ctx, ` - SELECT id, COALESCE(sku, ''), COALESCE(description, ''), COALESCE(category, ''), - COALESCE(uom_primary::text, 'EA'), - COALESCE(ROUND(base_price * 100)::bigint, 0), - COALESCE(ROUND(average_unit_cost * 100)::bigint, 0) - FROM products WHERE id = ANY($1)`, ids) + SELECT p.id, COALESCE(p.sku, ''), COALESCE(p.description, ''), COALESCE(p.category, ''), + COALESCE(p.uom_primary::text, 'EA'), + `+pos.WalkInPriceCentsSQL+`, + COALESCE(ROUND(p.average_unit_cost * 100)::bigint, 0) + FROM products p WHERE p.id = ANY($1)`, ids) if err != nil { return nil, err } diff --git a/core/internal/marketcheck/run.go b/core/internal/marketcheck/run.go index 8eff49f5c..97532c66c 100644 --- a/core/internal/marketcheck/run.go +++ b/core/internal/marketcheck/run.go @@ -17,7 +17,6 @@ import ( "sync" "unicode" - "github.com/gablelbm/gable/internal/ai" "github.com/google/uuid" ) @@ -35,6 +34,7 @@ const ( noteNoProduct = "This product was not found" allowedLinkPrefix = "https://www.homedepot.com/" + maxLinkLength = 2048 ) // Competitor is the flow's match for one item, cleaned. @@ -160,14 +160,13 @@ func (s *Service) Run(ctx context.Context, productIDs []uuid.UUID) (*Result, err if err != nil { return nil, err } - if !cfg.Configured { - return nil, ErrNotConfigured - } - hook, err := ai.OpenValue(s.vault, cfg.sealedID) - if err != nil || hook == "" { - // Saved but unreadable (the vault key changed): treat as not configured. + hook, base, _ := s.usable(cfg) + if hook == "" || base == "" { return nil, ErrNotConfigured } + cfg.ConnectURL = base + client := newGuardedClient(allowPrivateFor(hostnameOf(base))) + defer client.CloseIdleConnections() bandBP, _, err := parsePercent("band_pct", cfg.BandPct, 50) if err != nil { return nil, err @@ -232,8 +231,8 @@ func (s *Service) Run(ctx context.Context, productIDs []uuid.UUID) (*Result, err } req := flowRequest{Source: "gable", Yard: flowYard{Name: yard.Name, Town: yard.Town}, Store: flowStore{ID: cfg.StoreID, Zip: cfg.StoreZip, Label: cfg.StoreLabel}, - BandPercent: json.Number(cfg.BandPct), Items: items} - outcomes[bi] = s.callBatch(ctx, cfg.ConnectURL, hook, req) + BandPercent: json.Number(formatBP(bandBP)), Items: items} + outcomes[bi] = s.callBatch(ctx, client, cfg.ConnectURL, hook, req) }(bi) } wg.Wait() @@ -289,7 +288,7 @@ func applyRow(row *Row, fr flowRow, bandBP, floorBP, cost int64) { Confidence: int(math.Round(math.Min(100, math.Max(0, m.Confidence)))), UnitNote: cleanText(m.NormNote, 300), } - if strings.HasPrefix(m.Link, allowedLinkPrefix) { + if len(m.Link) <= maxLinkLength && strings.HasPrefix(m.Link, allowedLinkPrefix) { if u, err := url.Parse(m.Link); err == nil && u.Host == "www.homedepot.com" { c.Link = m.Link } @@ -317,7 +316,7 @@ func validPrice(p float64) bool { // callBatch posts one batch. Its failures are fixed words: the transport's // own error text holds the address, so it is never kept, logged or answered. -func (s *Service) callBatch(ctx context.Context, base, hook string, body flowRequest) batchOutcome { +func (s *Service) callBatch(ctx context.Context, client *http.Client, base, hook string, body flowRequest) batchOutcome { host := hostOf(base) fail := func(note, kind string) batchOutcome { s.log.Warn("market check batch failed", "host", host, "kind", kind, "items", len(body.Items)) @@ -334,8 +333,11 @@ func (s *Service) callBatch(ctx context.Context, base, hook string, body flowReq return fail(noteDidNotAnswer, "request") } req.Header.Set("Content-Type", "application/json") - resp, err := s.client.Do(req) + resp, err := client.Do(req) if err != nil { + if errors.Is(err, errDialRefused) { + return fail(noteDidNotAnswer, "blocked_address") + } if errors.Is(cctx.Err(), context.DeadlineExceeded) { return fail(noteTimedOut, "timeout") } @@ -384,6 +386,14 @@ func (s *Service) callBatch(ctx context.Context, base, hook string, body flowReq return out } +func hostnameOf(base string) string { + u, err := url.Parse(base) + if err != nil { + return "" + } + return u.Hostname() +} + func hostOf(base string) string { u, err := url.Parse(base) if err != nil { diff --git a/core/internal/marketcheck/service.go b/core/internal/marketcheck/service.go index ad328fd80..7ef8ca726 100644 --- a/core/internal/marketcheck/service.go +++ b/core/internal/marketcheck/service.go @@ -12,7 +12,6 @@ package marketcheck import ( "context" "log/slog" - "net/http" "time" "github.com/gablelbm/gable/internal/ai" @@ -75,7 +74,6 @@ type Service struct { tx TxRunner audit Auditor log *slog.Logger - client *http.Client timeout time.Duration } @@ -83,10 +81,6 @@ type Service struct { func NewService(store Store, catalog Catalog) *Service { return &Service{ store: store, catalog: catalog, log: slog.Default(), timeout: 30 * time.Second, - client: &http.Client{ - // A redirect is never followed: the answer is the 3xx itself, a failed batch. - CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, - }, } } @@ -107,5 +101,8 @@ func (s *Service) WithLogger(l *slog.Logger) *Service { return s } +// BatchTimeout is the longest one batch may wait for the flow. +func (s *Service) BatchTimeout() time.Duration { return s.timeout } + // WithBatchTimeout shortens the per batch context timeout (tests). func (s *Service) WithBatchTimeout(d time.Duration) *Service { s.timeout = d; return s } diff --git a/core/internal/marketcheck/settings.go b/core/internal/marketcheck/settings.go index d94ae8272..6a0cf715c 100644 --- a/core/internal/marketcheck/settings.go +++ b/core/internal/marketcheck/settings.go @@ -57,6 +57,9 @@ type Settings struct { BandPct string `json:"band_pct"` MarginFloorPct string `json:"margin_floor_pct"` Configured bool `json:"configured"` + // Notice is a plain sentence about the saved state when it needs the admin's + // attention (an id to enter again); empty otherwise. + Notice string `json:"notice"` } // SettingsInput is the body of the settings write. A nil field keeps what is @@ -214,10 +217,51 @@ func (s *Service) load(ctx context.Context) (loaded, error) { return l, nil } -// Settings answers the saved settings, never the webhook id. +const ( + noticeNewAddress = "Enter the webhook id again for the new address" + noticeUnreadable = "The saved webhook id cannot be read; enter it again" + noticeBadAddress = "The saved FB Connect address is not valid; enter it again" +) + +// usable re-validates what is stored, whatever path wrote it: the address +// with the save time parser, the id sealed (never a legacy plain value), opened +// and in the id's pattern. It answers the opened id and the canonical address, +// or a notice when the saved state cannot be used. +func (s *Service) usable(l loaded) (hook, base, notice string) { + if l.ConnectURL != "" { + canon, err := parseConnectURL(l.ConnectURL) + if err != nil || canon == "" || canon != l.ConnectURL { + return "", "", noticeBadAddress + } + base = canon + } + if l.sealedID == "" { + if base != "" { + return "", base, noticeNewAddress + } + return "", base, "" + } + if !ai.IsSealedValue(l.sealedID) { + return "", base, noticeUnreadable + } + id, err := ai.OpenValue(s.vault, l.sealedID) + if err != nil || !webhookIDPattern.MatchString(id) { + return "", base, noticeUnreadable + } + return id, base, "" +} + +// Settings answers the saved settings, never the webhook id. Configured is +// true only when the address and the id are both saved and usable. func (s *Service) Settings(ctx context.Context) (Settings, error) { l, err := s.load(ctx) - return l.Settings, err + if err != nil { + return Settings{}, err + } + hook, base, notice := s.usable(l) + l.Configured = hook != "" && base != "" + l.Notice = notice + return l.Settings, nil } // SaveSettings validates the whole input, then writes it in one transaction. @@ -302,6 +346,17 @@ func (s *Service) SaveSettings(ctx context.Context, in SettingsInput) (Settings, } } apply := func(ctx context.Context) error { + // A changed address does not carry the saved id to the new host: it is + // cleared unless this same request brings a new one. + if newURL, ok := writes[keyConnectURL]; ok && in.WebhookID == nil { + old, _, err := s.store.Read(ctx, keyConnectURL) + if err != nil { + return err + } + if old != newURL { + deletes = append(deletes, keyWebhookID) + } + } // One fixed key order, so two saves at once lock rows in the same // order and never deadlock. keys := make([]string, 0, len(writes)+len(deletes)) diff --git a/core/internal/pos/price.go b/core/internal/pos/price.go new file mode 100644 index 000000000..8b3d81544 --- /dev/null +++ b/core/internal/pos/price.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos + +// WalkInPriceCentsSQL is the counter's walk in price of a product in cents, +// as an expression over the products table aliased p. The counter's search +// and catalogue read it, and the Market check reads it too, so the two never +// drift apart. +const WalkInPriceCentsSQL = `COALESCE(ROUND(p.base_price * 100)::bigint, 0)` diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index add85726e..2c0372931 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -1560,7 +1560,7 @@ func counterStockScope(branch, grants int) string { func (r *PostgresRepository) SearchProducts(ctx context.Context, query string, limit int) ([]QuickSearchResult, error) { rows, err := r.ex(ctx).Query(ctx, ` - SELECT p.id, COALESCE(p.sku, ''), COALESCE(p.description, ''), COALESCE(ROUND(p.base_price * 100)::bigint, 0), + SELECT p.id, COALESCE(p.sku, ''), COALESCE(p.description, ''), `+WalkInPriceCentsSQL+`, COALESCE(p.uom_primary::text, 'EA'), COALESCE(ROUND(SUM(i.quantity - i.allocated) * 10000)::bigint, 0) FROM products p LEFT JOIN inventory i ON i.product_id = p.id`+counterStockScope(3, 4)+` WHERE (p.sku ILIKE $1 OR p.description ILIKE $1) @@ -1584,7 +1584,7 @@ func (r *PostgresRepository) SearchProducts(ctx context.Context, query string, l func (r *PostgresRepository) GetProductCatalog(ctx context.Context) ([]CatalogProduct, error) { rows, err := r.ex(ctx).Query(ctx, ` - SELECT p.id, COALESCE(p.sku, ''), COALESCE(p.description, ''), COALESCE(ROUND(p.base_price * 100)::bigint, 0), + SELECT p.id, COALESCE(p.sku, ''), COALESCE(p.description, ''), `+WalkInPriceCentsSQL+`, COALESCE(p.uom_primary::text, 'EA'), COALESCE(ROUND(SUM(i.quantity - i.allocated) * 10000)::bigint, 0) FROM products p LEFT JOIN inventory i ON i.product_id = p.id`+counterStockScope(1, 2)+` GROUP BY p.id, p.sku, p.description, p.base_price, p.uom_primary From fdf0af0d812bb52eddd69d2940379034dac68aea Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 10:26:30 -0700 Subject: [PATCH 17/17] marketcheck: the settings notice, the card and page words, the Tech Admin tab link, contract and docs Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/api/fragments/marketcheck.yaml | 25 ++++++++--- core/api/openapi.yaml | 19 ++++++-- docs/integrations/MARKET-CHECK-FLOW.md | 20 ++++++++- docs/parity/WALKS.md | 5 ++- docs/refactor/CONTRACT-CHANGES.md | 1 + .../pages/admin/pricing/MarketCheck.test.ts | 9 +++- .../src/pages/admin/pricing/MarketCheck.ts | 3 +- .../tech_admin/MarketCheckSettings.test.ts | 31 ++++++++++++- .../admin/tech_admin/MarketCheckSettings.ts | 3 ++ .../pages/admin/tech_admin/TechAdminPage.ts | 3 ++ .../admin/tech_admin/TechAdminTab.test.ts | 44 +++++++++++++++++++ .../desk/src/services/MarketCheckService.ts | 4 +- web/packages/api-client/src/schema.d.ts | 16 ++++++- 13 files changed, 165 insertions(+), 18 deletions(-) create mode 100644 web/apps/desk/src/pages/admin/tech_admin/TechAdminTab.test.ts diff --git a/core/api/fragments/marketcheck.yaml b/core/api/fragments/marketcheck.yaml index 0701f93ca..82f9f6d8f 100644 --- a/core/api/fragments/marketcheck.yaml +++ b/core/api/fragments/marketcheck.yaml @@ -36,7 +36,9 @@ paths: keeps the saved id, an empty string clears it; a new id is sealed with the vault (refused in words when no vault key is configured). The address is https://host[:port] with no user info, path, query or - fragment (http only for a loopback host). Answers the GET shape. + fragment (http only for a loopback host). A request that changes the + address clears the saved id unless it carries a new one. Answers the + GET shape. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: @@ -68,8 +70,10 @@ paths: the competitor's matched title and price, the difference and position recomputed from our own figures, and a suggested price that keeps the margin floor. A batch that fails marks only its own items error. 409 - not_configured when the address or the webhook id is not saved. Cost - is never in the answer and is never sent to the flow. + not_configured when the address or the webhook id is not saved or + usable. One run at a time per caller: a second answers 429. The + response gets its own write deadline past one batch. Cost is never in + the answer and is never sent to the flow. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: @@ -90,12 +94,18 @@ paths: "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/WireConflict" } "413": { $ref: "#/components/responses/PayloadTooLarge" } + "429": + description: This caller already has a market check running; one at a time. + content: + application/json: + schema: + $ref: "#/components/schemas/WireError" "500": { $ref: "#/components/responses/WireInternalError" } components: schemas: MarketCheckSettings: type: object - required: [connect_url, webhook_id_saved, flow_name, store_label, store_id, store_zip, band_pct, margin_floor_pct, configured] + required: [connect_url, webhook_id_saved, flow_name, store_label, store_id, store_zip, band_pct, margin_floor_pct, configured, notice] properties: connect_url: {type: string} webhook_id_saved: {type: boolean} @@ -105,7 +115,12 @@ components: store_zip: {type: string} band_pct: {type: string, description: A decimal percent from 0 to 50.} margin_floor_pct: {type: string, description: A decimal percent from 0 to 90.} - configured: {type: boolean} + configured: + type: boolean + description: True only when the address and the id are saved and usable (the stored id opens and the address is valid). + notice: + type: string + description: A plain sentence when the saved state needs the admin (an id to enter again); empty otherwise. MarketCheckSettingsRequest: type: object additionalProperties: false diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index bef7ec06f..1df175fb8 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -8234,7 +8234,7 @@ paths: tags: [pricing] summary: Save the Market check settings description: >- - Admin or owner. A field left out keeps what is saved. webhook_id absent keeps the saved id, an empty string clears it; a new id is sealed with the vault (refused in words when no vault key is configured). The address is https://host[:port] with no user info, path, query or fragment (http only for a loopback host). Answers the GET shape. + Admin or owner. A field left out keeps what is saved. webhook_id absent keeps the saved id, an empty string clears it; a new id is sealed with the vault (refused in words when no vault key is configured). The address is https://host[:port] with no user info, path, query or fragment (http only for a loopback host). A request that changes the address clears the saved id unless it carries a new one. Answers the GET shape. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: @@ -8261,7 +8261,7 @@ paths: tags: [pricing] summary: Check picked products against the live store description: >- - Admin or owner. Runs the FB Connect pricing flow for one to twelve distinct products in batches of three. Per product: our walk in price, the competitor's matched title and price, the difference and position recomputed from our own figures, and a suggested price that keeps the margin floor. A batch that fails marks only its own items error. 409 not_configured when the address or the webhook id is not saved. Cost is never in the answer and is never sent to the flow. + Admin or owner. Runs the FB Connect pricing flow for one to twelve distinct products in batches of three. Per product: our walk in price, the competitor's matched title and price, the difference and position recomputed from our own figures, and a suggested price that keeps the margin floor. A batch that fails marks only its own items error. 409 not_configured when the address or the webhook id is not saved or usable. One run at a time per caller: a second answers 429. The response gets its own write deadline past one batch. Cost is never in the answer and is never sent to the flow. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: @@ -8282,6 +8282,12 @@ paths: "403": {$ref: "#/components/responses/Forbidden"} "409": {$ref: "#/components/responses/WireConflict"} "413": {$ref: "#/components/responses/PayloadTooLarge"} + "429": + description: This caller already has a market check running; one at a time. + content: + application/json: + schema: + $ref: "#/components/schemas/WireError" "500": {$ref: "#/components/responses/WireInternalError"} /api/v1/matching/config: get: @@ -21921,7 +21927,7 @@ components: type: string MarketCheckSettings: type: object - required: [connect_url, webhook_id_saved, flow_name, store_label, store_id, store_zip, band_pct, margin_floor_pct, configured] + required: [connect_url, webhook_id_saved, flow_name, store_label, store_id, store_zip, band_pct, margin_floor_pct, configured, notice] properties: connect_url: {type: string} webhook_id_saved: {type: boolean} @@ -21931,7 +21937,12 @@ components: store_zip: {type: string} band_pct: {type: string, description: A decimal percent from 0 to 50.} margin_floor_pct: {type: string, description: A decimal percent from 0 to 90.} - configured: {type: boolean} + configured: + type: boolean + description: True only when the address and the id are saved and usable (the stored id opens and the address is valid). + notice: + type: string + description: A plain sentence when the saved state needs the admin (an id to enter again); empty otherwise. MarketCheckSettingsRequest: type: object additionalProperties: false diff --git a/docs/integrations/MARKET-CHECK-FLOW.md b/docs/integrations/MARKET-CHECK-FLOW.md index d5990ad66..73b44fc5a 100644 --- a/docs/integrations/MARKET-CHECK-FLOW.md +++ b/docs/integrations/MARKET-CHECK-FLOW.md @@ -18,6 +18,22 @@ Gable's server posts, once per batch of up to three items (at most four batches Each batch has its own timeout. Redirects are not followed; a 3xx is a failed batch. The answer is read up to 1 MB. +The webhook id is part of that path: it is the flow's own address form, so it is visible in FB Connect's and any +TLS terminating proxy's access logs, never in Gable's. Prefer a direct https route to FB Connect. Gable never logs, answers +or audits it. + +## Where the call may go + +- The saved address is re-validated at every run (https://host[:port], no user info, path, query or fragment; http only + for a loopback host), and the saved id must be sealed and match its pattern, or the run answers "not configured". +- A changed address clears the saved id unless the same save carries a new one, so the id is never sent to a host the + writer chose without entering it again. +- The client refuses, after name resolution and per connection, link local (the cloud metadata address among them), + unspecified and multicast addresses always, and loopback, private and shared (100.64.0.0/10) ranges unless the saved + address is itself a loopback or private literal address (or localhost). A self hosted FB Connect on a private network is + therefore addressed by its IP; a public name that resolves inward is refused. No environment proxy is used. +- One run at a time per caller; a second answers 429. + ## The request ```json @@ -61,9 +77,9 @@ as listed. - Title and note are cut to 200 and 300 characters with control characters removed. - A price must be finite, above zero and below 100000 dollars, or the row is an error row. - Confidence is clamped to 0 to 100. -- A link is kept only when it starts with `https://www.homedepot.com/`; any other is dropped. +- A link is kept only when it starts with `https://www.homedepot.com/` and is at most 2048 characters; any other is dropped. - The difference and the position are recomputed from Gable's own price and the competitor's `theirPrice`; the flow's own difference, if it sends one, is ignored. - The suggested price keeps the dealer's margin floor and is rounded in whole cents: the floor rounds up, a band - target rounds down. + target rounds down. When the floor wins on any row the reason is `held_at_floor`. - A failed batch (network, timeout, a non 200 answer, bad JSON, too large) marks only its own items as errors. diff --git a/docs/parity/WALKS.md b/docs/parity/WALKS.md index 7836544e2..307d7c2e6 100644 --- a/docs/parity/WALKS.md +++ b/docs/parity/WALKS.md @@ -271,6 +271,9 @@ Needs the demo seed, a vault key (`PAYMENT_VAULT_KEY`), an FB Connect address an 4. Check a suggestion by hand: a row above the band suggests the competitor's price plus the band, never under the margin floor (cost divided by one less the floor percent). 5. As a cashier or a sales user, `POST /api/v1/pricing/market-check` is a 403. -6. Clear the id in Tech Admin and open Market check again: it says the check is off, with a link to Tech Admin. +6. Clear the id in Tech Admin and open Market check again: it says the check is off, with a link that opens Tech Admin + on the Market check tab. +7. Change the FB Connect address and Save without entering the id again: the id shows empty with "Enter the webhook id + again for the new address", and the Market check says it is off until it is entered. None of the other walks of the plan are written yet; they land with their items. diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index a9d268373..1c01f9233 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -420,3 +420,4 @@ get an entry. | ACCOUNT-RULING-A | `GET /api/v1/accounts/{id}` | The account summary held the customer to the old finance wall: a user on a yard the customer is not linked to (by header, or with no header and grants that miss it) read it as 404 "account not found", although the Customers app lists every customer and the customer record opens (ruling A, ADR 0007 section 5.8) | The summary is the customer's dealer wide standing (balance, credit limit, open credits, unapplied cash; the same figures the counter's standing and the credit check read dealer wide, ADR 0007 section 5.8) and reads through ruling A's reach (`CustomerReadBranchForQuery`, `CustomerReadGrantsSubForQuery`): a user, an administrator and an unbound key reach every customer; a key bound to a branch keeps its pin and reads a customer outside it as 404, as before. Route, roles, body and error envelope are unchanged. Branch bound key class stays CONFINED (`CustomerBranch(path customer)`, `TestKeyBranchPin_PosAndAccountRows`). `GET /api/v1/accounts/{id}/transactions` and `GET /api/v1/ar/customers/{id}/statement` keep the finance documents' wall unchanged, as do each payment and credit memo: the subledger still answers 404 for a customer outside the caller's reach (the route now runs that wall as its own gate instead of through the summary), and the desk shows a calm note in the ledger's place | | DASH-AR-NET | `GET /api/v1/dashboard/summary` field `outstanding_ar` | The sum of `total_amount` over the yard's `UNPAID` and `PARTIAL` invoices, so part payments, open credit memos and unapplied cash were not netted and the figure disagreed with AR Aging for the same yard. | The yard's net open receivable as AR Aging reads it today (invoice open amounts, plus open credit memo amounts, less unapplied cash, inside the caller's wall). `outstanding_ar_count` is unchanged (open invoices). In the clockwindow golden `dashboard.summary.outstanding_ar` moved from 11865877 to 11056347; nothing else moved. | | MARKET-CHECK (item 112 c) | `GET /api/v1/admin/market-check/settings`, `PUT /api/v1/admin/market-check/settings`, `POST /api/v1/pricing/market-check` (new, no migration) | No way to read our prices against a live competitor store. | The settings (FB Connect address, a sealed write only webhook id, flow name, store label, id and ZIP, band percent, margin floor percent) live in `system_settings` under `market_check.`, written by admin and owner (the machine key scope is `admin:settings`); the id is sealed with the payment vault and refused in words with no vault key, and is never answered, logged or audited. The run (admin and owner, the pricing rules' role wall) takes one to twelve distinct product ids, calls the configured flow in batches of three, and answers per product our walk in price, the competitor's match, our recomputed difference and position and a suggested price that keeps the margin floor (409 `not_configured` when unset). Cost is never sent or answered. Key allowlist: the run and the settings write REFUSED to a branch bound key, the settings read a STATED LIMIT. No golden changes. | +| MARKET-CHECK-FIX1 | `GET/PUT /api/v1/admin/market-check/settings`, `POST /api/v1/pricing/market-check` | The settings answer had no word about a state needing the admin; a changed address kept the sealed id; a stored address or id written by another path was used as is; the run could be cut by the server's write timeout and run twice at once. | The settings answer gains `notice` (a plain sentence: "Enter the webhook id again for the new address", "The saved webhook id cannot be read; enter it again", or the bad saved address); `configured` is true only when the stored address and id are usable. A PUT that changes the address clears the saved id unless it carries a new one. The run re-validates the stored address and id, refuses link local, unspecified and multicast addresses at dial time (and private ranges for a public name), sets its own response deadline past one batch, answers 429 to a second run by the same caller, and reads `held_at_floor` whenever the floor wins. No golden changes. | diff --git a/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts b/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts index 80061a9fc..40b970b53 100644 --- a/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts +++ b/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts @@ -21,6 +21,7 @@ const SETTINGS = { band_pct: '5', margin_floor_pct: '15', configured: true, + notice: '', } function product(n: number, sku = `DL_SPF_${n}`) { @@ -98,10 +99,16 @@ describe('Market check screen', () => { expect(text(q(el, '[data-testid="not-configured"]'))).toContain( "Market check is off. An admin enters the FB Connect address and the flow's webhook id in Tech Admin.", ) - expect(q(el, '[data-testid="not-configured"] a').getAttribute('href')).toBe('/admin') + expect(q(el, '[data-testid="not-configured"] a').getAttribute('href')).toBe('/admin?tab=market-check') expect(el.querySelector('[data-testid="run"]')).toBeNull() }) + it('says why it is off when the server has a notice (an id that cannot be read)', async () => { + settings = { ...SETTINGS, configured: false, notice: 'The saved webhook id cannot be read; enter it again' } + const el = await open() + expect(text(q(el, '[data-testid="not-configured"]'))).toContain('The saved webhook id cannot be read; enter it again') + }) + it('shows the header, the store label, the badge and the first pick', async () => { const el = await open() expect(text(el)).toContain('Market check') diff --git a/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts b/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts index 3449a3653..5af5cb574 100644 --- a/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts +++ b/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts @@ -208,8 +208,9 @@ export class GableMarketCheck extends LitElement {

Market check is off.

An admin enters the FB Connect address and the flow's webhook id in - Tech Admin. + Tech Admin.

+ ${this._settings?.notice ? html`

${this._settings.notice}

` : nothing}
`; } diff --git a/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.test.ts b/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.test.ts index d65ff6f2d..c3d95ba29 100644 --- a/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.test.ts +++ b/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.test.ts @@ -9,7 +9,7 @@ import { mount, update, flush, q, text, jsonResponse } from '../../../test/dom' const BASE = { connect_url: 'https://connect.example.test', webhook_id_saved: true, flow_name: 'Pricing flow', store_label: '', - store_id: '', store_zip: '', band_pct: '5', margin_floor_pct: '15', configured: true, + store_id: '', store_zip: '', band_pct: '5', margin_floor_pct: '15', configured: true, notice: '', } let current: Record @@ -64,6 +64,35 @@ describe('Market check settings card', () => { expect(el.innerHTML).not.toContain('test-hook') }) + it('shows the server notice beside the id field (a changed address cleared the id)', async () => { + current = { ...BASE, webhook_id_saved: false, configured: false, notice: 'Enter the webhook id again for the new address' } + const el = await open() + expect(text(q(el, '[data-testid="notice"]'))).toBe('Enter the webhook id again for the new address') + expect(q(el, 'input[name="webhook_id"]').value).toBe('') + }) + + it('shows the notice an address change brings back from the save', async () => { + const el = await open() + const input = q(el, 'input[name="connect_url"]') + input.value = 'https://other.example.test' + input.dispatchEvent(new Event('input')) + await update(el, {}) + // the server clears the id for a new address and says so + puts.length = 0 + vi.stubGlobal('fetch', vi.fn(async (_i: RequestInfo | URL, init?: RequestInit) => { + if ((init?.method ?? 'GET') === 'PUT') { + puts.push(JSON.parse(String(init!.body))) + return jsonResponse({ ...BASE, connect_url: 'https://other.example.test', webhook_id_saved: false, configured: false, + notice: 'Enter the webhook id again for the new address' }) + } + return jsonResponse(current) + })) + await click(el, '[data-testid="save"]') + expect(text(q(el, '[data-testid="notice"]'))).toContain('Enter the webhook id again for the new address') + expect(el.querySelector('[data-testid="webhook-saved"]')).toBeNull() + expect(q(el, 'input[name="webhook_id"]').type).toBe('password') + }) + it('shows a password input with autocomplete off when none is saved', async () => { current = { ...BASE, webhook_id_saved: false, configured: false } const el = await open() diff --git a/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.ts b/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.ts index 561b22fe2..b5200dc4f 100644 --- a/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.ts +++ b/web/apps/desk/src/pages/admin/tech_admin/MarketCheckSettings.ts @@ -138,6 +138,9 @@ export class GableMarketCheckSettings extends LitElement { class="px-3 py-1 border border-red-500/30 text-red-400 rounded text-sm hover:bg-red-500/10">Clear ` : nothing} + ${this._settings.notice + ? html`

${this._settings.notice}

` + : nothing} ${showInput ? html`
${icon(Shield, 16, 'absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500')} diff --git a/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts b/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts index af1422dd8..b404675e2 100644 --- a/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts +++ b/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts @@ -75,6 +75,9 @@ export class TechAdminPage extends LitElement { connectedCallback() { super.connectedCallback(); + // A link may name the tab to open (the Market check page links to its own card). + const named = new URLSearchParams(window.location.search).get('tab'); + if (named === 'market-check') this.activeTab = named; this._loadKeys(); this._loadAISettings(); this._loadRoutingSettings(); diff --git a/web/apps/desk/src/pages/admin/tech_admin/TechAdminTab.test.ts b/web/apps/desk/src/pages/admin/tech_admin/TechAdminTab.test.ts new file mode 100644 index 000000000..cec0dec73 --- /dev/null +++ b/web/apps/desk/src/pages/admin/tech_admin/TechAdminTab.test.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** Tech Admin opens on the tab the address names (the Market check page links to its own card). */ +import { describe, it, expect, afterEach, vi } from 'vitest' +import './TechAdminPage' +import type { TechAdminPage } from './TechAdminPage' +import { mountAsync, text, jsonResponse } from '../../../test/dom' + +afterEach(() => { + document.body.innerHTML = '' + history.replaceState(null, '', '/') + vi.unstubAllGlobals() +}) + +function serve() { + vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/market-check/settings')) { + return Promise.resolve(jsonResponse({ + connect_url: '', webhook_id_saved: false, flow_name: 'FB Connect pricing flow', store_label: '', store_id: '', + store_zip: '', band_pct: '5', margin_floor_pct: '15', configured: false, notice: '', + })) + } + return Promise.resolve(jsonResponse([])) + })) +} + +describe('Tech Admin tab from the address', () => { + it('opens the Market check card for ?tab=market-check', async () => { + serve() + history.replaceState(null, '', '/admin?tab=market-check') + const el = await mountAsync('gable-tech-admin') + expect(el.querySelector('gable-market-check-settings')).not.toBeNull() + expect(text(el)).toContain('Flow webhook id') + }) + + it('opens API Keys with no tab named, and ignores an unknown one', async () => { + serve() + history.replaceState(null, '', '/admin?tab=nonsense') + const el = await mountAsync('gable-tech-admin') + expect(el.querySelector('gable-market-check-settings')).toBeNull() + }) +}) diff --git a/web/apps/desk/src/services/MarketCheckService.ts b/web/apps/desk/src/services/MarketCheckService.ts index 69610977f..ce9c10860 100644 --- a/web/apps/desk/src/services/MarketCheckService.ts +++ b/web/apps/desk/src/services/MarketCheckService.ts @@ -20,10 +20,12 @@ export interface MarketCheckSettings { band_pct: string; margin_floor_pct: string; configured: boolean; + /** A plain sentence when the saved state needs the admin (an id to enter again); empty otherwise. */ + notice: string; } /** A field left out keeps what is saved; webhook_id '' clears the saved id. */ -export type MarketCheckSettingsInput = Partial> & { +export type MarketCheckSettingsInput = Partial> & { webhook_id?: string; }; diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index 0bc02d07d..305fa87bb 100644 --- a/web/packages/api-client/src/schema.d.ts +++ b/web/packages/api-client/src/schema.d.ts @@ -3470,7 +3470,7 @@ export interface paths { get: operations["marketCheckGetSettings"]; /** * Save the Market check settings - * @description Admin or owner. A field left out keeps what is saved. webhook_id absent keeps the saved id, an empty string clears it; a new id is sealed with the vault (refused in words when no vault key is configured). The address is https://host[:port] with no user info, path, query or fragment (http only for a loopback host). Answers the GET shape. + * @description Admin or owner. A field left out keeps what is saved. webhook_id absent keeps the saved id, an empty string clears it; a new id is sealed with the vault (refused in words when no vault key is configured). The address is https://host[:port] with no user info, path, query or fragment (http only for a loopback host). A request that changes the address clears the saved id unless it carries a new one. Answers the GET shape. */ put: operations["marketCheckSaveSettings"]; post?: never; @@ -3491,7 +3491,7 @@ export interface paths { put?: never; /** * Check picked products against the live store - * @description Admin or owner. Runs the FB Connect pricing flow for one to twelve distinct products in batches of three. Per product: our walk in price, the competitor's matched title and price, the difference and position recomputed from our own figures, and a suggested price that keeps the margin floor. A batch that fails marks only its own items error. 409 not_configured when the address or the webhook id is not saved. Cost is never in the answer and is never sent to the flow. + * @description Admin or owner. Runs the FB Connect pricing flow for one to twelve distinct products in batches of three. Per product: our walk in price, the competitor's matched title and price, the difference and position recomputed from our own figures, and a suggested price that keeps the margin floor. A batch that fails marks only its own items error. 409 not_configured when the address or the webhook id is not saved or usable. One run at a time per caller: a second answers 429. The response gets its own write deadline past one batch. Cost is never in the answer and is never sent to the flow. */ post: operations["marketCheckRun"]; delete?: never; @@ -10500,7 +10500,10 @@ export interface components { band_pct: string; /** @description A decimal percent from 0 to 90. */ margin_floor_pct: string; + /** @description True only when the address and the id are saved and usable (the stored id opens and the address is valid). */ configured: boolean; + /** @description A plain sentence when the saved state needs the admin (an id to enter again); empty otherwise. */ + notice: string; }; MarketCheckSettingsRequest: { connect_url?: string; @@ -22446,6 +22449,15 @@ export interface operations { 403: components["responses"]["Forbidden"]; 409: components["responses"]["WireConflict"]; 413: components["responses"]["PayloadTooLarge"]; + /** @description This caller already has a market check running; one at a time. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WireError"]; + }; + }; 500: components["responses"]["WireInternalError"]; }; };