// Package catalog — see doc.go for the package summary. // // This file holds the published Projection struct: the wire shape that // commerce modules receive. Every producer adapter (writeradmin, future CMS // catalog adapter, ...) emits this exact shape; every consumer (cart, // checkout, order, UCP add-to-cart) reads from this exact shape. Per-tenant // field remapping happens in Schema + Builder (schema.go, builder.go). // // Mapping source JSON → Projection is per-tenant and lives in Schema. // The consensus mapping for DefaultSchema() is: // // item_id ← int coercion of source[ItemIdField] (omitempty; 0 = unset) // id ← IDPrefix + SKU (e.g. "sku:") // sku ← source[SKUField] // title ← source[TitleField] (omitempty, optional) // price_inc_vat ← coercive parse of source[PriceField] × PriceScale // currency ← source[CurrencyField], or FixedCurrency // tax_class ← source[TaxClassField] (string identifier) // inventory_tracked ← bool coercion of source[InventoryTrackedField] // drop_ship ← bool coercion of source[DropShipField] // category ← source[CategoryField] (omitempty) // brand ← source[BrandField] (omitempty) // image ← source[ImageField] (omitempty) // updated_at ← Unix-ms from source[UpdatedAtField] OR stamped at // publish time when UpdatedAtSource == StampAtPublish // // SCALE (rate-bearing fields): any field that carries a tax rate uses the // platform-wide basis-points-of-a-percent convention (rate × 100; 25% = 2500, // 12.5% = 1250, 8.1% = 810). Never float. Producer-side fields stay in // minor currency units (öre / cents) for amounts — already int64. TaxClass // on the wire is a STRING identifier; numeric rate resolution is a platform/tax // concern (out of package scope by design). package catalog import ( "encoding/json" "git.k6n.net/mats/platform/money" ) // Projection is the published, versioned catalog entry that commerce modules // consume. Finder owns updating it; cart / checkout / order read it. // // Versioning: UpdatedAt is a monotonic Unix-millisecond timestamp stamped by // the producer at the time the AMQP event is emitted (or read from a // designated source field, see Schema.UpdatedAtSource). Consumers, when // revalidating, treat a strictly greater UpdatedAt as the authoritative // replacement and refresh the local copy. // // Field rationale: // // ItemID — opaque numeric catalog item id from the producer's // source (e.g. product service `/api/get/{sku}` integer // `id` field, or the bygglagret raw row `item_id`). // Carried here as uint32 to mirror go-cart-actor's // proto messages.AddItem.ItemId which dedupes cart lines // on equality. Zero means "producer didn't publish" — a // legitimate value when the source has no integer id // (the cart fall-back path uses HTTP PRODUCT_BASE_URL // to recover the id in that case). // ID — opaque published string identity; SKU alone is not a // stable cross-module id (different scopes reuse it). // SKU — domain identifier; the join key cart already uses. // PriceIncVat — display + checkout projection; NOT transactional truth. // System of record for the total is settled via the // checkout/order payload — see platform-uniforming-plan.md. // Currency — optional but recommended; absent ⇒ caller supplies. // TaxClass — class identifier (string). Numeric rate resolution is // a platform/tax concern (see platform/tax.Provider). This // struct never carries a numeric rate; that keeps the // rate-scale convention in one place and keeps the wire // free of consumer-policy values. // InventoryTracked / DropShip — fulfillment-routing flags. The C1 contract // asks for these so the cart can pre-mark drop-ship // items before checkout and inventory can opt-out of // tracking for non-stock items. // Category / Brand / Image — display projection only. // UpdatedAt — version stamp for snapshot + revalidate. // // WIRE-COMPAT: adding a field is forward+backward-safe. Old snapshot // readers ignore unknown JSON keys; new readers populate the field from // the JSON token. The on-disk snapshot binary header/record layout is // unchanged \u2014 only the per-blob JSON payload gains a key. type Projection struct { ItemID uint32 `json:"item_id,omitempty"` ID string `json:"id"` SKU string `json:"sku"` Title string `json:"title,omitempty"` PriceIncVat money.Cents `json:"price_inc_vat"` Currency string `json:"currency,omitempty"` // TaxClass uses a string identifier (see platform/tax class constants). // Empty string means "unclassified"; the consumer's tax.Provider is what // resolves the identifier to a numeric rate. TaxClass string `json:"tax_class"` InventoryTracked bool `json:"inventory_tracked"` DropShip bool `json:"drop_ship"` Category string `json:"category,omitempty"` Brand string `json:"brand,omitempty"` Image string `json:"image,omitempty"` UpdatedAt int64 `json:"updated_at"` } // PayloadKey is the envelope key producers use when publishing a slice of // projections over the bus as an `event.Event.Payload` body. Consumers parse // this exact key — never a per-producer ad-hoc name — so the wire shape is // stable. const PayloadKey = "catalog_projection" // Encode serialises a Projection to JSON suitable for the event.Payload. // Kept as a package-level helper so producers and tests share the exact wire // shape; any future wire renaming lands here, not in every caller. func (p Projection) Encode() ([]byte, error) { return json.Marshal(p) } // EncodeBatch serialises a slice to a JSON array. func EncodeBatch(batch []Projection) ([]byte, error) { return json.Marshal(batch) } // ProjectionUpdate is one entry in a CatalogProjectionPublished batch. An // upsert carries a fully-built Projection. A delete sets Deleted=true; only SKU // (and ID) are then meaningful and the consumer drops that SKU from its // snapshot / cache. Modelling delete as a flag (not a nil payload) keeps a // change-set's upserts and deletes together in one ordered event. type ProjectionUpdate struct { Projection Deleted bool `json:"deleted,omitempty"` } // EncodeUpdates serialises a batch for the event.Event.Payload body. func EncodeUpdates(batch []ProjectionUpdate) ([]byte, error) { return json.Marshal(batch) } // DecodeUpdates parses a CatalogProjectionPublished payload — the consumer side // of EncodeUpdates. func DecodeUpdates(raw []byte) ([]ProjectionUpdate, error) { var out []ProjectionUpdate err := json.Unmarshal(raw, &out) return out, err } // Decode parses a Projection from JSON. Symmetric with Encode so consumer // tests can round-trip a fixture without depending on the Builder. func Decode(raw []byte) (Projection, error) { var p Projection err := json.Unmarshal(raw, &p) return p, err }