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

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