Files
platform/catalog/projection.go
T
2026-06-28 17:52:49 +02:00

135 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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:
//
// id ← IDPrefix + SKU (e.g. "sku:<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:
//
// ID — opaque published 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.
type Projection struct {
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
}