diff --git a/core/api/ROUTES.txt b/core/api/ROUTES.txt index e2263963c..64f8560ff 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 @@ -339,6 +341,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..82f9f6d8f --- /dev/null +++ b/core/api/fragments/marketcheck.yaml @@ -0,0 +1,188 @@ +# 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). 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: + 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 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: + 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" } + "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, notice] + 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 + 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 + 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 a2e59df81..d889a3c88 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -8212,6 +8212,83 @@ 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). 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: + 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 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: + 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"} + "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: operationId: matchingConfigGet @@ -21966,6 +22043,89 @@ 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, notice] + 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 + 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 + 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 fce1de940..c3557a3eb 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" @@ -401,6 +402,14 @@ 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}) + registerMarketCheck(mux, marketCheckSvc, wall.keyWall, db) + // Category Pricing Engine (feature-flagged) if strings.EqualFold(os.Getenv("CATEGORY_PRICING_ENABLED"), "true") { catPricingRepo := pricing.NewCategoryRepository(db) @@ -1383,3 +1392,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 6e08d2ad7..951b1aa48 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/order" @@ -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 new file mode 100644 index 000000000..5fadb1c76 --- /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, s.Reason = floor, ReasonHeldAtFloor + } + 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/compute_test.go b/core/internal/marketcheck/compute_test.go new file mode 100644 index 000000000..2a222ca81 --- /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 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}, + {"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/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/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) + } +} diff --git a/core/internal/marketcheck/handler.go b/core/internal/marketcheck/handler.go new file mode 100644 index 000000000..289dcb225 --- /dev/null +++ b/core/internal/marketcheck/handler.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +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 + 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, 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). +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) + } + // 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) + return + } + writeJSON(w, http.StatusOK, out) +} diff --git a/core/internal/marketcheck/helpers_test.go b/core/internal/marketcheck/helpers_test.go new file mode 100644 index 000000000..478bbce59 --- /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 *flowStub, 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/postgres.go b/core/internal/marketcheck/postgres.go new file mode 100644 index 000000000..4c37fd273 --- /dev/null +++ b/core/internal/marketcheck/postgres.go @@ -0,0 +1,116 @@ +// 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/internal/pos" + "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 +// 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. +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 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 + } + 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/run.go b/core/internal/marketcheck/run.go new file mode 100644 index 000000000..97532c66c --- /dev/null +++ b/core/internal/marketcheck/run.go @@ -0,0 +1,403 @@ +// 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/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/" + maxLinkLength = 2048 +) + +// 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 + } + 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 + } + 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(formatBP(bandBP)), Items: items} + outcomes[bi] = s.callBatch(ctx, client, 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 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 + } + } + 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, 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)) + 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 := 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") + } + 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 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 { + return "unknown" + } + return u.Host +} 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/service.go b/core/internal/marketcheck/service.go new file mode 100644 index 000000000..7ef8ca726 --- /dev/null +++ b/core/internal/marketcheck/service.go @@ -0,0 +1,108 @@ +// 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" + "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 + 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, + } +} + +// 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 +} + +// 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 new file mode 100644 index 000000000..6a0cf715c --- /dev/null +++ b/core/internal/marketcheck/settings.go @@ -0,0 +1,395 @@ +// 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" + "sort" + "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"` + // 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 +// 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 +} + +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) + 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. +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 { + // 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)) + for k := range writes { + keys = append(keys, k) + } + 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 + } + } + 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) +} 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) + } + } +} 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/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 bc673d94d..b059ebc2d 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -1564,7 +1564,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) @@ -1680,7 +1680,7 @@ func (r *PostgresRepository) CounterTiles(ctx context.Context, q TileQuery) (Til 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 diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 471ec91fa..6a9be5491 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": {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"}, @@ -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", diff --git a/docs/integrations/MARKET-CHECK-FLOW.md b/docs/integrations/MARKET-CHECK-FLOW.md new file mode 100644 index 000000000..73b44fc5a --- /dev/null +++ b/docs/integrations/MARKET-CHECK-FLOW.md @@ -0,0 +1,85 @@ + + +# 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 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 +{ + "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/` 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. 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 728407927..307d7c2e6 100644 --- a/docs/parity/WALKS.md +++ b/docs/parity/WALKS.md @@ -255,4 +255,25 @@ 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. +### 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 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 + 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 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 66a5d1339..6460df19d 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -420,6 +420,8 @@ 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 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. | | PRODUCT-PICTURES-1 | the product wire (`ProductView`: `GET /api/v1/products`, `GET /api/v1/products/{id}`, the product writes' bodies and the `product` of `GET /api/v1/products/{id}/detail`) and the inventory levels' product summary (`GET /api/v1/inventory?include=product`) | The drawn product pictures showed only on the counter's tiles; the quote builder's lines, the inventory list and the product page had no picture path to show | Each carries `image_url`: the product's picture path while the `catalog.product_images` setting is "on", null while it is off or absent and for a product with none. The create answers null (its body is the row as written); the margin, dimensions and lead time writes answer the read, so they carry the path while the setting is on. No other wire change, no new route and no change to any wall. In the goldens `image_url: null` is added to the product, product_item, pim_media and inventory_transfer responses (the base seed leaves the setting off) | | POS-RECEIPT-1 (item 111) | pos: `GET /api/v1/pos/transactions/{id}/receipt`, `POST /api/v1/pos/transactions/{id}/receipt/email` (new routes) | The Sale complete card's Print receipt and Email receipt called the documents print route and the invoice email route, mounted for admin, owner, sales and finance, so a cashier got a 403 from both. | The counter's own pair, mounted for admin, owner and cashier with the same reach as the sale read (the branch context, the wall and a bound key's pin; a sale the caller cannot read is the sale read's 404). Print answers the sale's invoice document by the documents print route's own renderer (same body and content type); email sends the sale's invoice to the account's contact by the invoice email route's own function (same 202 `{"status":"queued"}`, same delivery record). A sale that is not completed or has no invoice is a 409 blocker `no_receipt`; an email with no contact (a sale that names no customer or the shared walk in customer, whatever address that row holds, or an account with no email address) is a 409 blocker `no_contact` in words, and nothing is sent; only a completed sale has a receipt (a voided or held sale that keeps its invoice is `no_receipt`). Both routes are WALLED in the bound key allowlist. The documents routes and their roles are unchanged. `posTileList`'s description now says a counter sale's own invoice (origin POS) is not counted. | | AI-CLAUDE-FIX1 | `POST /api/v1/parsing/upload`, `POST /api/v1/purchase-orders/{id}/freight`; `GET /api/v1/apps` golden `apps`; pim: `POST /api/v1/products/{id}/pim/generate/image` | A file of a type the chosen provider cannot read (a BMP under Anthropic) got the canned demo list at the intake and a bare 400 at freight. The apps catalog described the AI client as "OpenRouter client". Image generation under Anthropic answered 409 AI off. | The unreadable type answers 415 `unsupported_type` in the wire envelope with the words naming what the provider reads; no canned list. The catalog line reads "Provider routed AI client (OpenRouter or Anthropic), KeyStore, and model routing (library)." and the `apps` golden is re-recorded for that one string. Image generation answers 202 under Anthropic and draws an illustration, stored only after a strict sanitiser. | 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..40b970b53 --- /dev/null +++ b/web/apps/desk/src/pages/admin/pricing/MarketCheck.test.ts @@ -0,0 +1,302 @@ +// 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, + notice: '', +} + +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 +/** 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 }) + } + 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?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') + 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('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('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: '2', board_width_in: '4', board_length_ft: '8', + random_length: false, available: avail, ...over, + }) + const items = [ + { ...product(1, 'TW_WIPE'), available: '900.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) => { + 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', '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() + // 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() + 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 }) + 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() + 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..5af5cb574 --- /dev/null +++ b/web/apps/desk/src/pages/admin/pricing/MarketCheck.ts @@ -0,0 +1,373 @@ +// 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 { fetchWithAuth } from '../../../services/fetchClient'; +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 yard's top lumber sellers. */ +const API_URL = import.meta.env.VITE_API_URL || ''; +const DEFAULT_PICK = 6; +const COMPETITOR = 'Home Depot'; + +interface Picked { + id: string; + sku: string; + description: string; +} + +/** + * 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 dimensionalLumber(catalog: Product[]): Picked[] { + const num = (v: unknown) => (v == null ? NaN : Number.parseFloat(String(v))); + return catalog + .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 })); +} + +/** "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 tiles = await this._topSellers(); + 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 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'; + } + this._picked = chosen; + } + } catch (err) { + this._loadError = apiErrorMessage(err, 'Could not load the Market check'); + } finally { + this._loading = false; + } + } + + /** + * 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 }]; + 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. +

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

${this._settings.notice}

` : nothing} +
+ `; + } + + 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} + ${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')} + { 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 c1e07702c..ba92c40ba 100644 --- a/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts +++ b/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts @@ -5,12 +5,13 @@ 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 { aiHeadline, aiStatusLine, imageWords, modelLabel, providerLabel } from '../../../lib/aiWords.ts'; 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'; @@ -19,7 +20,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[] = []; @@ -75,6 +76,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(); @@ -425,6 +429,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 }, @@ -1097,6 +1102,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/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/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.test.ts b/web/apps/desk/src/services/MarketCheckService.test.ts new file mode 100644 index 000000000..9b7bf87db --- /dev/null +++ b/web/apps/desk/src/services/MarketCheckService.test.ts @@ -0,0 +1,47 @@ +// 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.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 () => { + 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.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.mockImplementation(() => { throw new TypeError("Failed to fetch") }) + expect(await failure()).toBe('Failed to fetch') + }) +}) diff --git a/web/apps/desk/src/services/MarketCheckService.ts b/web/apps/desk/src/services/MarketCheckService.ts new file mode 100644 index 000000000..ce9c10860 --- /dev/null +++ b/web/apps/desk/src/services/MarketCheckService.ts @@ -0,0 +1,115 @@ +// 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 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> & { + 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[]; +} + +/** + * 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 = { + 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 { + 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(); + }, +}; diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index c28711591..84c3cf3fc 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). 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; + 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 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; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/matching/config": { parameters: { query?: never; @@ -10510,6 +10554,76 @@ 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; + /** @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; + /** @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: { @@ -22350,6 +22464,103 @@ 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"]; + /** @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"]; + }; + }; matchingConfigGet: { parameters: { query?: never;