This commit is contained in:
mats committed 2026-06-28 17:52:49 +02:00
1 parent e706f7d907
commit 21bcb63440
42 files changed
+5903

No files matched your search

+386
View File
@@ -0,0 +1,386 @@
// 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)
}