package main import ( messages "git.k6n.net/mats/go-cart-actor/proto/cart" "git.k6n.net/mats/platform/catalog" ) // taxClassToBp maps the canonical TaxClass string identifiers published on // catalog.Projection to basis-points-of-a-percent (rate × 100) — the single // platform-wide rate scale used by AddItem.Tax (int32 basis points), the // mutation registry, and the tax.Provider Compute formula. // // Values reflect the standard tariff names used by the Nordic configuration. // Per-country / per-tenant numeric resolution (e.g. Finland 25.5%, Ireland // 13.5%) is delegated to platform/tax and is a follow-up: this static map // gets the common case right (2500 / 1200 / 600 / 0) and lets the cache path // ship while the platform/tax lookup is wired into the cart pool server. var taxClassToBp = map[string]int{ "standard": 2500, "reduced": 1200, // common reduced rate (e.g. SE/NO food) "lowered": 600, // super-reduced "zero": 0, // zero-rated (export, healthcare, ...) "exempt": 0, // similarly untaxed, separate nominal class } // resolveTaxBp returns the basis-point rate for a TaxClass string, falling // back to platform "standard" (25%) when the class is unrecognised. The // default matches the grain's existing behaviour (AddItem falls back to 2500 // when m.Tax == 0 — see pkg/cart/mutation_add_item.go). func resolveTaxBp(class string) int { if bp, ok := taxClassToBp[class]; ok { return bp } return 2500 } // HasRequiredFields asserts the catalog Projection carries enough authoritative // fields for the cart to build a complete AddItem WITHOUT an HTTP fallback to // PRODUCT_BASE_URL. Required: // // PriceIncVat > 0 (treats zero as "unbus-fed", not "free"; positive-only // matches every other cache-authoritative field) // TaxClass != "" (string identifier; numeric rate resolved via // resolveTaxBp at the same scale as the HTTP path) // ItemID != 0 (uint32 dedup key; zero means the producer didn't // publish one, so we don't claim we know the id) // // Display fields (Title, Image) and fulfillment flags (InventoryTracked, // DropShip) are optional — when present they populate the line, when absent // the proto-zero value is acceptable. func HasRequiredFields(p catalog.Projection) bool { return p.PriceIncVat.Int64() > 0 && p.TaxClass != "" && p.ItemID != 0 } // BuildAddItemFromCache constructs a complete AddItem message directly from a // cache hit that satisfies HasRequiredFields. The caller has already confirmed // the projection is well-formed; we map every cache-visible carrier onto the // proto fields the existing HTTP path would have constructed. // // Fields mapped: // // Sku ← input // ItemId ← p.ItemID (uint32 dedup key) // Quantity ← input // Country ← input // StoreId ← input // Price ← p.PriceIncVat.Int64() // Tax ← int32(resolveTaxBp(p.TaxClass)) // Name ← p.Title (may be empty) // Image ← p.Image (may be empty) // InventoryTracked ← p.InventoryTracked // DropShip ← p.DropShip // // Fields intentionally left blank (cache-only gap; the schema doesn't yet // carry them and the audience for those fields is downstream of the cart line // — they are NOT required for line dedup or pricing): // // SellerId / SellerName — marketplace split; not on Projection yet. // Stock — queried synchronously from inventory at // decision points (per docs/inventory-shape.md). // OrgPrice / Discount — pre-discount price; a future Projection // extension when promotion previews need it. // ExtraJson — dynamic product data (configurator // width/height etc.); a future schema extension // for cache-only-skip-HTTP for groups with // configurator children. // // IMPORTANT: this helper is for TOP-LEVEL lines only. Configurator children // re-price based on parent dimensions (ToItemAddMessage's parent != nil // path); since the Projection does not yet carry width/height, callers must // keep the existing HTTP-fetch flow for groups with children. // // Tombstoned SKUs never reach this helper; callers check idx.IsDeleted(sku) // first and short-circuit. func BuildAddItemFromCache(sku string, qty int, country string, storeId *string, p catalog.Projection) *messages.AddItem { return &messages.AddItem{ Sku: sku, ItemId: p.ItemID, Quantity: int32(qty), Country: country, StoreId: storeId, Price: p.PriceIncVat.Int64(), Tax: int32(resolveTaxBp(p.TaxClass)), Name: p.Title, Image: p.Image, InventoryTracked: p.InventoryTracked, DropShip: p.DropShip, } } // ApplyProjectionOverlay overlays the cache's authoritative catalog facts onto // an AddItem message that was just produced by a PRODUCT_BASE_URL HTTP fetch. // // Authoritative-from-cache fields (these WIN over the HTTP-fetched value when // both are present, because the bus is the live stream): // // Price (bus has post-class-overrides; product service has static) // Tax (TaxClass → bp via resolveTaxBp; only when TaxClass is set — // an empty TaxClass means the producer didn't classify, // so we leave the HTTP-fetched rate intact rather than // quietly swapping in a default 2500 and miscategorising) // Title (canonical, post-trim) // Image (canonical) // ItemID (positive bus values override HTTP; zero is the // documented "no id" signal, so a tombstone's zero or an // unbus-fed cache value won't clobber a real HTTP id.) // InventoryTracked (only flips to true; false is the zero value in proto // and may be unset on the wire, so we don't second-guess) // DropShip (only flips to true; same reasoning) // // The HTTP-fetched values remain authoritative for fields the cache does not // carry: SellerId / SellerName (marketplace split), Stock (per docs/inventory- // shape.md stock is queried synchronously from inventory, not cached), // OrgPrice / Discount (not yet on the projection schema), and the dynamic // ExtraJson product data (configurator options — width/height used by // accessory child-pricing). // // SKUs that satisfy HasRequiredFields AND have no configurator children // route through BuildAddItemFromCache instead — see the cache-only fast path // in buildItemGroups / AddSkuToCartHandler. ApplyProjectionOverlay remains // for the cold-grace case (unbus-fed cache entry) AND for groups with // children (the parent's HTTP fetch is required for child dimension-driven // pricing; Overlay is applied after the fetch). // // Tombstoned SKUs (catalog.projection_published with deleted=true) → the // caller checks idx.IsDeleted(sku) before this helper and short-circuits. func ApplyProjectionOverlay(msg *messages.AddItem, p catalog.Projection) { if msg == nil { return } // Price: positive bus values override HTTP. A zero PriceIncVat is treated // as "no value" rather than "free". if p.PriceIncVat.Int64() > 0 { msg.Price = p.PriceIncVat.Int64() } // Tax: only override when the bus has classified the SKU. An empty TaxClass // means the producer had nothing to say about VAT; the HTTP rate is more // trustworthy in that case than a guessed 2500. if p.TaxClass != "" { msg.Tax = int32(resolveTaxBp(p.TaxClass)) } // ItemId: positive bus values override HTTP. Zero means the producer // didn't publish an id (some sources have no integer id) — we leave the // HTTP-fetched id intact in that case rather than zeroing out a real id. if p.ItemID != 0 { msg.ItemId = p.ItemID } // Display fields: only override if non-empty so the HTTP-fanned value // remains in place when the cache hasn't populated them yet. if p.Title != "" { msg.Name = p.Title } if p.Image != "" { msg.Image = p.Image } // Flags: only flip to true (positive boolean signals). A false is the // proto-zero value and not worth overriding. if p.InventoryTracked { msg.InventoryTracked = true } if p.DropShip { msg.DropShip = true } }