// 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] // seller_id ← uint32 coercion of source[SellerIdField] (0 = unset) // seller_name ← source[SellerNameField] (omitempty) // lead_time_days ← int coercion of source[LeadTimeField] (0 = unset) // category ← source[CategoryField] (omitempty) // category2 ← source[Category2Field] (omitempty) // category3 ← source[Category3Field] (omitempty) // category4 ← source[Category4Field] (omitempty) // category5 ← source[Category5Field] (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" "git.k6n.net/mats/platform/projection" ) // 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. // Category2–Category5 extend category with deeper taxonomy levels for // promotions engine multi-level matching and tracking personas. // Each level is optional; an empty string means "not published". // 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. Attributes json.RawMessage `json:"attributes,omitempty"` TaxClass string `json:"tax_class"` InventoryTracked bool `json:"inventory_tracked"` DropShip bool `json:"drop_ship"` // Drop-ship routing carriers (Phase 1 of docs/archive/plan-dropship.md). // SellerID mirrors AddItem.ItemId coercion rules: 0 = "producer didn't // publish" and downstream keeps whatever the HTTP fallback fetched. // SellerName is what checkout writes into order-line // Properties["supplier"] (the dropship-fulfill flow groups on it). // LeadTimeDays is supplier lead time in days, 0 = unset. SellerID uint32 `json:"seller_id,omitempty"` SellerName string `json:"seller_name,omitempty"` LeadTimeDays int `json:"lead_time_days,omitempty"` Category string `json:"category,omitempty"` Category2 string `json:"category2,omitempty"` Category3 string `json:"category3,omitempty"` Category4 string `json:"category4,omitempty"` Category5 string `json:"category5,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 } // ── Bus framing contract (shared by producer + consumer) ───────────────────── // // catalog.projection_published carries two channels distinguished only by // event.Event.Meta. The producer (slask-finder writeradmin) sets these keys and // the consumer (ProjectionStore, consumer.go) reads them — one definition so the // two never drift. The framing itself is domain-neutral and lives in // platform/projection; catalog re-exports the constants so the producer/consumer // keep their catalog.* names. See docs/catalog-projection.md. // // - A FULL SNAPSHOT is a framed begin → chunk* → end stream, all tagged with one // MetaEpoch. begin/end carry MetaCount (total projections) for completeness // validation; chunks carry the projections. // - A DELTA has NO MetaSnapshotPhase key, and MetaEpoch names the snapshot base // it builds on (consumers ignore a delta older than the base they hold). const ( MetaSnapshotPhase = projection.MetaSnapshotPhase // begin|chunk|end; absent ⇒ delta MetaEpoch = projection.MetaEpoch // int64 (Unix-ms), monotonic across rebuilds MetaSeq = projection.MetaSeq // chunk sequence within a snapshot MetaCount = projection.MetaCount // total projections (begin/end only) MetaTenant = projection.MetaTenant // namespace tag SnapshotBegin = projection.SnapshotBegin SnapshotChunk = projection.SnapshotChunk SnapshotEnd = projection.SnapshotEnd )