187 lines
8.2 KiB
Go
187 lines
8.2 KiB
Go
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
|
||
}
|
||
}
|