387 lines
13 KiB
Go
387 lines
13 KiB
Go
// 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{
|
||
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)
|
||
}
|
||
|
||
// 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)
|
||
}
|