// Builder applies a Schema to a generic source map[string]any and returns // the platform-projected Projection. Pure function — no I/O, no mutation, // safe to call concurrently. Coercions are best-effort: // // - missing source field ⇒ Schema default (FixedCurrency/DefaultTaxClass // when present, otherwise empty/zero) // - wrong-typed source value ⇒ the Builder either coerces (bools from // truthy strings, ints from numeric strings, json.Number, etc.) or // returns ErrProjectionBuild so the caller can decide whether to // log+skip or fail. // // Builder never panics on bad input — a malformed source line is a log+skip, // not a process crash. // // Three coercers cover realistic producer output shapes: // // - stock json.Unmarshal gives numeric fields as float64 unless // `dec.UseNumber()` is on, in which case they come as json.Number. // - json.Number preserves up to ~17 digits of precision; float64 // silently loses precision past 2^53. A producer that wants exact // prices sets UseNumber(); this Builder must accept the result. // - some producers serialise via reflection and emit `int` / `int64` // directly; this Builder must accept those too. // // All amounts leave the Builder as money.Cents (integer minor units). The // only float-side conversion lives in platform/money (FromFloatMajor) and // is the explicit lossy-edge; once in Cents, no float is ever summed, // multiplied, or compared downstream. // // SCALE SEMANTICS used by coercePrice: // // - PriceScale == 1: the source value is ALREADY in minor units. // * For string / json.Number: ParseInt directly (integer form only — // the fractional-vs-low-scale guard above rejects "."-containing // inputs). // * For int / int64: identity (multiply by 1). // * For float64 / float32: the float must be integral (else rejected); // cast to int gives minor units directly. // // - PriceScale > 1 (e.g. 100): the source value is in MAJOR units. // * For string / json.Number: ParseCents treats the decimal form as // MAJOR and returns MINOR cents exactly — no further multiply. // * For int / int64: integer multiply (lossless). // * For float64 / float32: float multiply + round-half-away (lossy). package catalog import ( "encoding/json" "errors" "fmt" "strconv" "strings" "time" "git.k6n.net/mats/platform/money" ) // ErrProjectionBuild is returned when a source value cannot be coerced // into the Projection shape. Callers typically log + skip so a single bad // item doesn't poison a whole batch. var ErrProjectionBuild = errors.New("catalog: projection build failed") // Apply is the pure helper signature: schema, source map → Projection, // error. Use the package-level BuildFromMap helper for the most common // shape (schema + map → Projection); use Apply when you want fine control. func Apply(s Schema, source map[string]any) (Projection, error) { if s.SKUField == "" || s.PriceField == "" { return Projection{}, fmt.Errorf("%w: schema missing sku_field or price_field", ErrProjectionBuild) } if source == nil { return Projection{}, fmt.Errorf("%w: nil source map", ErrProjectionBuild) } sku := stringOf(source, s.SKUField) if sku == "" { return Projection{}, fmt.Errorf("%w: missing SKU at %q", ErrProjectionBuild, s.SKUField) } priceRaw, ok := source[s.PriceField] if !ok { return Projection{}, fmt.Errorf("%w: missing price at %q", ErrProjectionBuild, s.PriceField) } price, err := coercePrice(priceRaw, s.PriceScale) if err != nil { return Projection{}, fmt.Errorf("%w: price at %q: %w", ErrProjectionBuild, s.PriceField, err) } cur := "" if s.CurrencyField != "" { cur = stringOf(source, s.CurrencyField) } if cur == "" { cur = s.FixedCurrency } tax := "" if s.TaxClassField != "" { tax = stringOf(source, s.TaxClassField) } if tax == "" { tax = s.DefaultTaxClass } id := sku if s.IDPrefix != "" { id = s.IDPrefix + sku } upd, err := coerceUpdatedAt(s, source) if err != nil { return Projection{}, fmt.Errorf("%w: updated_at: %w", ErrProjectionBuild, err) } truthy := s.TrueValues if truthy == nil { truthy = DefaultTrueValues() } p := Projection{ ItemID: uint32Of(source, s.ItemIdField), ID: id, SKU: sku, Title: stringOf(source, s.TitleField), PriceIncVat: price, Currency: cur, TaxClass: tax, InventoryTracked: boolOf(source, s.InventoryTrackedF, truthy), DropShip: boolOf(source, s.DropShipF, truthy), Category: stringOf(source, s.CategoryField), Brand: stringOf(source, s.BrandField), Image: stringOf(source, s.ImageField), UpdatedAt: upd, } return p, nil } // BuildFromMap is the convenience: schema + map → Projection with error. // Use this from producer adapters where the source is a parsed map[string]any. func BuildFromMap(s Schema, source map[string]any) (Projection, error) { return Apply(s, source) } // stringOf returns the value at key as a string. JSON unmarshalling gives // strings as string; we accept the common numeric source shapes too so a // map[string]any that came from a different upstream parser (e.g. typed // structs serialised with one round of reflection) still works. // // json.Number is included because precision-preserving producers turn it on // via json.Decoder.UseNumber(); without this case the Builder would reject // every projection in the catalogue. func stringOf(m map[string]any, key string) string { if key == "" { return "" } v, ok := m[key] if !ok || v == nil { return "" } switch x := v.(type) { case string: return x case json.Number: // Use Number preserves up to ~17 digits as a string-token. For SKUs // digit-strings >2^53 (rare but possible), UseNumber is the only // way to round-trip safely. String() gives back the raw digits. return x.String() case bool: if x { return "true" } return "false" case float64: if x == float64(int64(x)) { return strconv.FormatInt(int64(x), 10) } return strconv.FormatFloat(x, 'f', -1, 64) case int: return strconv.Itoa(x) case int64: return strconv.FormatInt(x, 10) } return fmt.Sprintf("%v", v) } // uint32Of returns the value at key as a uint32. Best-effort coercion: // json.Number / int / int64 / float (integral check) / truthy numeric // string. Returns 0 for any missing / non-uint32-representable value — // Projection.ItemID = 0 is the documented "no id" signal that the cart's // overlay path uses to fall back to PRODUCT_BASE_URL. func uint32Of(m map[string]any, key string) uint32 { if key == "" { return 0 } v, ok := m[key] if !ok || v == nil { return 0 } switch x := v.(type) { case uint32: return x case uint64: if x > 0xFFFFFFFF { return 0 // overflow – treat as unset, downstream falls back } return uint32(x) case int: if x < 0 || x > 0xFFFFFFFF { return 0 } return uint32(x) case int64: if x < 0 || x > 0xFFFFFFFF { return 0 } return uint32(x) case float64: if x != float64(uint32(x)) || x < 0 || x > 0xFFFFFFFF { return 0 // fractional or out-of-range } return uint32(x) case json.Number: s := x.String() n, err := strconv.ParseUint(s, 10, 32) if err != nil { return 0 } return uint32(n) case string: s := strings.TrimSpace(x) if s == "" { return 0 } n, err := strconv.ParseUint(s, 10, 32) if err != nil { return 0 } return uint32(n) } return 0 } // boolOf returns the value at key coerced through truthy. nil/missing keys // are false; strings lower-cased and matched against truthy; bools passed // through; numerics "!= 0"; json.Number goes through Float64. func boolOf(m map[string]any, key string, truthy []string) bool { if key == "" { return false } v, ok := m[key] if !ok || v == nil { return false } switch x := v.(type) { case bool: return x case string: low := strings.ToLower(strings.TrimSpace(x)) for _, t := range truthy { if low == t { return true } } return false case float64: return x != 0 case int: return x != 0 case int64: return x != 0 case json.Number: if f, err := x.Float64(); err == nil { return f != 0 } return false } return false } // coercePrice normalises a price source value to money.Cents (integer minor // units). The schema's PriceScale determines whether the source is already // in minor units (scale==1) or in MAJOR units that need interpretation // (scale > 1, typically 100). See the package doc for the full table. // // The fractional-vs-low-scale guard REJECTS a fractional value when scale ≤ 1 // at any of the float / string / json.Number arms — that's almost certainly a // tenant who forgot PriceScale=100 and would have produced a silent 100×-off // price. Loud-fail beats silent corruption. // // At scale > 1, decimals come through money.ParseCents (exact, integer-only) // rather than ParseFloat (lossy past 2^53). Float arms remain the explicit // lossy bits, intentionally isolated. func coercePrice(v any, scale int) (money.Cents, error) { if scale < 0 { return 0, fmt.Errorf("negative price_scale: %d", scale) } effective := int64(scale) if effective < 1 { effective = 1 } // Fractional-vs-low-scale guard is inlined at each arm so the error // message keeps the actual value (the float stringification is noisier // than the JSON-token ".eE" check, so a single helper would lose // fidelity). switch x := v.(type) { case float64: if x != float64(int64(x)) && effective == 1 { return 0, fmt.Errorf("fractional price %v with PriceScale=1 — did you forget PriceScale=100?", x) } return money.Cents(roundHalfAwayFromZero(x * float64(effective))), nil case float32: if x != float32(int64(x)) && effective == 1 { return 0, fmt.Errorf("fractional price %v with PriceScale=1 — did you forget PriceScale=100?", x) } return money.Cents(roundHalfAwayFromZero(float64(x) * float64(effective))), nil case int: return money.Cents(int64(x) * effective), nil case int64: return money.Cents(x * effective), nil case string: s := strings.TrimSpace(x) if s == "" { return 0, fmt.Errorf("empty price string") } // Scale 1 with a fractional character set ⇒ foot-gun 4. if effective == 1 && strings.ContainsAny(s, ".eE") { return 0, fmt.Errorf("fractional price %q with PriceScale=1 — did you forget PriceScale=100?", s) } if effective == 1 { // Scale 1 means source IS minor units. Remaining forms are // integer decimal strings — ParseInt directly. ParseCents would // misread "14995" as 14995.00 SEK = 1499500. n, err := strconv.ParseInt(s, 10, 64) if err != nil { return 0, fmt.Errorf("parse price %q at scale=1: %w", x, err) } return money.Cents(n), nil } // Scale > 1: source is MAJOR units. ParseCents interprets the // decimal form as MAJOR and returns MINOR (cents) EXACTLY — no // further multiply. if c, err := money.ParseCents(s); err == nil { return c, nil } f, err := strconv.ParseFloat(s, 64) if err != nil { return 0, fmt.Errorf("parse price %q: %w", x, err) } return money.Cents(roundHalfAwayFromZero(f * float64(effective))), nil case json.Number: s := x.String() if effective == 1 && strings.ContainsAny(s, ".eE") { return 0, fmt.Errorf("fractional price %q with PriceScale=1 — did you forget PriceScale=100?", s) } if effective == 1 { // x is the original json.Number — Int64() works. if i, err := x.Int64(); err == nil { return money.Cents(i), nil } n, err := strconv.ParseInt(s, 10, 64) if err != nil { return 0, fmt.Errorf("parse price json.Number %q at scale=1: %w", s, err) } return money.Cents(n), nil } // Scale > 1: ParseCents token-aware path. ParseCents interprets // the token as MAJOR-units decimal and returns MINOR cents exactly. if c, err := money.ParseCents(s); err == nil { return c, nil } f, err := x.Float64() if err != nil { return 0, fmt.Errorf("parse price json.Number %q: %w", s, err) } return money.Cents(roundHalfAwayFromZero(f * float64(effective))), nil } return 0, fmt.Errorf("unsupported price type %T", v) } // roundHalfAwayFromZero rounds half-away-from-zero instead of banker's // rounding so e.g. 99.995 → 100 (with scale=1) not 99, and -99.995 → -100 // not -99. We accept the very small skew in favour of deterministic // conventional rounding for signed amounts (refunds, discounts). func roundHalfAwayFromZero(f float64) int64 { if f >= 0 { return int64(f + 0.5) } return int64(f - 0.5) } // coerceUpdatedAt returns Unix-ms for the Projection.UpdatedAt stamp based // on Schema.UpdatedAtSource. // // IMPORTANT contract (foot-gun 3, resolved): // // - StampAtPublish is the RESERVED deliberate-fallback. Builder sets // time.Now().UnixMilli(). Every rebuild re-stamps every projection, // invalidating every consumer's whole cache even when nothing changed. // Pick this only when the producer genuinely has no version stamp. // // - UseSourceField (DefaultSchema, BygglagretSchema) reads the value // from source[Schema.UpdatedAtField]. If the field is MISSING or the // value can't be parsed, Apply returns ErrProjectionBuild (wrapped) // rather than stamp-now'ing. That keeps UpdatedAt a true content // version across rebuilds — same source content ⇒ same UpdatedAt ⇒ // consumer caches stay valid. // // Consumers compare UpdatedAt with "strictly greater wins" semantics; a // silent stamp-now fallback would defeat this contract. func coerceUpdatedAt(s Schema, source map[string]any) (int64, error) { if s.UpdatedAtSource == StampAtPublish || s.UpdatedAtField == "" { return time.Now().UnixMilli(), nil } v, ok := source[s.UpdatedAtField] if !ok || v == nil { return 0, fmt.Errorf("missing updated_at at %q (UseSourceField requires the source to supply it)", s.UpdatedAtField) } switch x := v.(type) { case int64: return x, nil case int: return int64(x), nil case float64: return int64(x), nil case json.Number: if i, err := x.Int64(); err == nil { return i, nil } f, err := x.Float64() if err != nil { return 0, fmt.Errorf("updated_at json.Number %q: %w", x.String(), err) } return int64(f), nil case string: t := strings.TrimSpace(x) if t == "" { return 0, fmt.Errorf("empty updated_at string at %q (UseSourceField requires a value)", s.UpdatedAtField) } n, err := strconv.ParseInt(t, 10, 64) if err != nil { return 0, fmt.Errorf("parse updated_at %q: %w", x, err) } return n, nil } return 0, fmt.Errorf("unsupported updated_at type %T at %q", v, s.UpdatedAtField) }