Files
platform/event/event.go
T
mats 7c2a34cc7a feat(shop-sales): ledger contracts + docs for consumer PSP sales tracking
platform/order + platform/event gain the sale.paid and refund.issued wire
contracts (per-rate VatBuckets, PSP provider, timestamps) that drive the
ledger's SHOP-/REFUND- vouchers (go-cart-actor 2b819bd). docs/status.md
records the shop-sales + PSP settlement stack (tickets 17 + 18); the
wayfinder ledger tracker gains the two closed tickets and their Decisions
entries. Bumps go-cart-actor + backoffice (PSP float stat).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-17 20:01:58 +02:00

284 lines
15 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 event is the platform's shared event vocabulary: one envelope shape
// (Event) and a small set of Type constants for the event meanings that
// genuinely cross module boundaries. It imports only the stdlib — never an app
// module, never a transport library — so every module can depend on it.
//
// Discipline: a Type constant earns its place only when a real producer emits it
// AND it is meaningful to another module. We do not pre-declare aspirational
// types. See docs/platform-event-messaging-sketch.md.
package event
import (
"context"
"encoding/json"
"time"
)
// Type is the semantic event kind — the cross-module contract. A module may use
// its own private kinds internally; only meanings that cross a boundary belong
// here.
type Type string
const (
// Editorial (graph-cms). Emitted by the CMS outbound bridge on content-graph
// mutations. In-repo consumers are TBD (external generators today); this is
// the first envelope producer, so the constants exist where they are emitted.
ContentCreated Type = "content.created"
ContentUpdated Type = "content.updated"
ContentDeleted Type = "content.deleted"
// Catalog (finder). These meanings already cross modules today — finder
// writer → reader and → backoffice (MountFinderListener) — but still travel
// as finder's legacy ChangeTopic strings on the wire. The constants document
// the shared vocabulary; the wire migration onto the envelope is a later step.
CatalogItemChanged Type = "catalog.item_changed" // legacy: finder "item_added"
CatalogSnapshotReady Type = "catalog.snapshot_ready" // legacy: finder "snapshot_ready"
CatalogSettingsChanged Type = "catalog.settings_changed" // legacy: finder "settings_change"
CatalogFacetChanged Type = "catalog.facet_changed" // legacy: finder "facet_change"
CatalogAccessoryChanged Type = "catalog.accessory_changed" // legacy: finder "accessory_change"
// CatalogProjectionPublished carries the commerce-facing catalog Projection
// (platform/catalog) — producer maps raw items → Projection once, consumers
// (cart / checkout / order / UCP) read the typed shape. Distinct from
// CatalogItemChanged (raw bodies, consumed by inventory/backoffice): a
// product index emits BOTH; a non-product index emits neither projection.
// Payload is a []catalog.ProjectionUpdate batch (upserts + deletes per SKU).
CatalogProjectionPublished Type = "catalog.projection_published"
CatalogSortOverride Type = "catalog.sort_override" // finder-internal: per-session/group/popularity overrides
CatalogFieldSortOverride Type = "catalog.field_sort_override" // finder-internal: live field ordering overrides
// Inventory (go-redis-inventory core, emitted by the cart-inventory service).
// A coarse stock-level crossing (none/low/high) for one SKU+location —
// never a quantity. Exact stock is a synchronous query against the inventory
// service; only level crossings cross a module boundary. Payload is an
// inventory.LevelChanged (platform/inventory). First consumer: finder stock
// overlay (display hints). See docs/inventory-shape.md.
InventoryLevelChanged Type = "inventory.level_changed"
// Commerce (order). Emitted by the order service saga after an order is
// placed-and-paid (a durable outbox event). The authoritative "a sale
// happened" fact — multiple modules react independently (inventory commits
// stock, fulfillment allocates) without checkout coupling to any of them.
// Payload is an order.Created (platform/order). See docs/inventory-shape.md
// and docs/fulfillment-stance.md.
OrderCreated Type = "order.created"
// InvoiceIssued is emitted when an order is invoiced (pay-later placement or
// manual invoice issuance). Payload is an order.InvoiceIssued (platform/order).
InvoiceIssued Type = "invoice.issued"
// InvoicePaid is emitted for EVERY registered payment on an invoiced order
// (each event = one payment fact with its own amount/paidAt; the ledger
// books 1930→1510 per event). Payload is an order.InvoicePaid (platform/order).
InvoicePaid Type = "invoice.paid"
// InvoiceCredited is emitted when a credit note is issued against an issued
// invoice. Payload is an order.InvoiceCredited (platform/order): the credited
// amount + per-rate VAT split, which the ledger books as the reversal
// voucher (1510 C / 304x D / 261x D per rate).
InvoiceCredited Type = "invoice.credited"
// SalePaid is emitted when a consumer (PSP) order is captured — the
// accounting fact for shop sales: amount + per-rate VAT buckets + payment
// method + capture timestamp. Distinct from InvoiceIssued: invoice orders
// book via invoice.issued, so SalePaid must NOT fire for them. Payload is an
// order.SalePaid (platform/order). Consumer: cmd/ledger (aggregates into the
// daily SHOP- voucher).
SalePaid Type = "sale.paid"
// RefundIssued is emitted when a refund is issued against a captured PSP
// order (refund-order / cancel-order flows). Payload is an
// order.RefundIssued (platform/order): the refund amount + pro-rata VAT
// split. The ledger books the reversal voucher (clearing C / 304x D /
// 261x D per rate), idempotent per refund id. Invoice orders reverse via
// credit notes (invoice.credited), so RefundIssued never fires for them.
RefundIssued Type = "refund.issued"
// Analytics (tracking). Replaces the integer enum 0–6 that finder and
// slask-tracking use on the `tracking` topic today. Producer: slask-finder
// (pkg/tracking/rabbit-tracking.go). Consumer: slask-tracking
// (pkg/events/rabbit-tracking.go).
TrackSession Type = "track.session" // 0 — session start
TrackSearch Type = "track.search" // 1 — search query
TrackClick Type = "track.click" // 2 — item click / product view
TrackCart Type = "track.cart" // 3,4 — add to / remove from cart
TrackImpression Type = "track.impression" // 5 — impression
TrackAction Type = "track.action" // 6 — custom action (wishlist, etc.)
TrackPageView Type = "track.page_view"
TrackPageEngaged Type = "track.page_engaged"
TrackPageAction Type = "track.page_action"
TrackExperiment Type = "track.experiment"
// TrackPurchase — cut 23. Post-checkout funnel closure; emitted by
// event_handlers.go::TrackPurchase on a purchase request. Replaces
// 5 hand-written `pevent.Type("track.purchase")` literals with this
// typed constant.
TrackPurchase Type = "track.purchase"
// TrackPromotionView / TrackPromotionClick — storefront promotion
// attribution. Producer: the storefront promotion-badge islands, PLP
// promo badges, bundle picker and cart bundle upsell (lib/track.ts
// beacons → POST /api/track/promotion). Consumer: slask-tracking
// (pkg/view PromoStats) — per-promotion view/click counters for the
// promotions admin. Payload is the wire PromotionEvent (promotion id,
// action, placement, optional item id). These ride the browser beacon
// path only (no AMQP producer today), so they stay out of the
// `track.*` RabbitMQ routing pattern by construction.
TrackPromotionView Type = "track.promotion_view"
TrackPromotionClick Type = "track.promotion_click"
// TrackEvent — free-form named storefront event (custom analytics
// signals the storefront wires ad hoc: open_cart, plp_sort_change,
// plp_filter_change, …). Producer: lib/track.ts trackEvent() beacons /
// the data-track attribute island (POST /api/track/event). Consumer:
// slask-tracking (pkg/view CustomEvent → pkg/customstats) — per-name
// counters for the tracking read surface. Payload is the wire
// CustomEvent (name + flat string props). Browser-beacon path only,
// like the promotion pair.
TrackEvent Type = "track.event"
// Newsletter campaign attribution (review 3.8). Producer: the order
// service's MailerSend webhook receiver (cmd/order/newsletter-webhook.go)
// when MailerSend reports an open / click for a newsletter send. Consumer:
// slask-tracking (pkg/events/rabbit-tracking.go) — feeds per-send
// open/click rates for the backoffice delivery log. Payload is an
// event.CampaignEvent. Wire routing keys `track.campaign_open` /
// `track.campaign_click` ride the existing `track.*` pattern on the
// catalog exchange.
TrackCampaignOpen Type = "track.campaign_open"
TrackCampaignClick Type = "track.campaign_click"
// Wishlist attribution (W6d of docs/plan-wishlist-favorites.md).
// Producers: profile service (share-page read) and cart service
// (list→cart conversion). Consumer: slask-tracking
// (pkg/events/rabbit-tracking.go) — feeds the saved-together
// co-occurrence group. Payload is an event.WishlistEvent — product
// ids only, never owner identity (C5 pseudonymity). Wire routing
// keys are `track.wishlist_viewed` / `track.wishlist_to_cart` so
// they ride the existing `track.*` pattern on the catalog exchange.
WishlistViewed Type = "wishlist.viewed" // public share-page read
WishlistToCart Type = "wishlist.to_cart" // list → cart conversion
// Wishlist mutation outbox (W6 hardening of docs/plan-wishlist-favorites.md).
// Producers: profile service (owner-scoped wishlist mutations, emitted
// from the UCP handlers on successful applies) and cart service (cart→list
// conversion). No in-repo consumer yet — these are the durable domain
// facts that future consumers bind to (price-drop / back-in-stock
// notifications, merchandising analytics, GDPR audit). Wire routing keys
// mirror the Type (`wishlist.created`, `wishlist.item_added`, …,
// `cart.to_wishlist`) on the catalog exchange. Unlike the tracking
// attribution pair above, these ride their own routing keys — NOT the
// `track.*` pattern — so slask-tracking's existing `track.*` binding
// does not pick them up. Payload: event.WishlistMutationEvent (profile)
// / event.CartToWishlistEvent (cart). See docs/amqp-topics.md.
WishlistCreated Type = "wishlist.created" // explicit user-created list (not the implicit default materialisation)
WishlistItemAdded Type = "wishlist.item_added" // one per SKU added (single-add and batch paths)
WishlistItemRemoved Type = "wishlist.item_removed"
WishlistShared Type = "wishlist.shared" // share (re)generated — token itself never leaves the service
CartToWishlist Type = "cart.to_wishlist" // cart → list conversion (cart service)
)
// CampaignEvent is the payload for the newsletter campaign attribution
// events (TrackCampaignOpen / TrackCampaignClick). Deliberately pseudonymous
// (C5): it carries the send id (the `newsletter:{sendID}` MailerSend tag
// parsed from the webhook) and a sha256 hex hash of the recipient email,
// never the address itself. At is unix seconds (MailerSend's created_at;
// falls back to arrival time when absent).
//
// URL is the link that was clicked (TrackCampaignClick only): the webhook
// receiver stamps the MailerSend click URL so slask-tracking can aggregate
// top-clicked links per send. Empty for opens and for clicks whose payload
// carried no URL.
type CampaignEvent struct {
SendID string `json:"sendId"`
EmailHash string `json:"emailHash"`
At int64 `json:"at,omitempty"`
URL string `json:"url,omitempty"`
}
// WishlistEvent is the payload for the wishlist attribution events
// (WishlistViewed / WishlistToCart). Deliberately lean: list id + catalog
// item ids only — no owner id, no list name, no share token, so the
// pseudonymous tracking contract (C5) holds on the wire.
type WishlistEvent struct {
ListId uint32 `json:"listId"`
ItemIds []uint32 `json:"itemIds"`
}
// WishlistMutationEvent is the payload for the wishlist mutation outbox
// events (WishlistCreated / WishlistItemAdded / WishlistItemRemoved /
// WishlistShared). Fields are optional per event:
//
// wishlist.created → ListId + Name
// wishlist.item_added → ListId + Sku + ItemId
// wishlist.item_removed → ListId + Sku (+ ItemId when known)
// wishlist.shared → ListId + Name
//
// No owner identity, no share token (C5): a consumer can correlate by list
// id but never attribute a list to a customer from the event alone.
type WishlistMutationEvent struct {
ListId uint32 `json:"listId"`
Name string `json:"name,omitempty"`
Sku string `json:"sku,omitempty"`
ItemId uint32 `json:"itemId,omitempty"`
}
// CartToWishlistEvent is the payload for cart.to_wishlist (cart service).
// Carries the pseudonymised owner profile id (the cart's UserId — a
// customer-scoped opaque uint64 id, never an email/name) plus the list the
// lines landed in and the SKUs copied. Unlike the tracking pair, this is a
// domain outbox event on its own routing key; a future consumer (e.g.
// price-drop) needs the owner to address the customer.
type CartToWishlistEvent struct {
OwnerId uint64 `json:"ownerId"`
ListId uint32 `json:"listId"`
ListName string `json:"listName,omitempty"`
Skus []string `json:"skus,omitempty"`
}
// KnownTypes lists every event that has a declared Track* constant above.
// New pevent constants MUST also be added here so IsKnownType stays in
// sync. Used by HTTP handlers' forward-validation (e.g. SessionsPanel
// ?event_types= param, cut 25) so caller's typo's surface as a 400
// rather than silently dropping unknown tokens.
// KnownTypes is deliberately Track*-only: IsKnownType validates the
// user-facing `track.*` event-type query surface (SessionsPanel
// ?event_types= etc.). The wishlist attribution types (WishlistViewed /
// WishlistToCart) are consumed exclusively over AMQP and never appear in
// that HTTP param surface, so they stay out of this list (the drift-guard
// test enforces the Track* discipline).
var KnownTypes = []Type{
TrackSession, TrackSearch, TrackClick, TrackCart, TrackPurchase,
TrackImpression, TrackAction, TrackPageView, TrackPageEngaged,
TrackPageAction, TrackExperiment,
TrackPromotionView, TrackPromotionClick, TrackEvent,
// Track* constants without a browser beacon: consumed over AMQP only,
// but the drift-guard test requires every declared Track* constant to
// appear here so the HTTP ?event_types= surface stays exhaustive.
TrackCampaignOpen, TrackCampaignClick,
}
// IsKnownType reports whether s matches one of the declared Track*
// constants. Case-sensitive + exact-match. Callers MUST pass the canonical
// lowercase form (e.g. "track.search", NOT "Track.Search" or
// "TRACK.SEARCH"). Track* constants are declared lowercase in this file,
// and this function matches the literal string value of each constant.
// Callers should validate with this BEFORE casting untrusted input to Type.
func IsKnownType(s string) bool {
for _, t := range KnownTypes {
if string(t) == s {
return true
}
}
return false
}
// Event is the one envelope. Payload is per-Type, agreed between producer and
// consumer (json.RawMessage, not a god-struct). A nil Payload means "the subject
// changed; fetch the latest state."
type Event struct {
ID string `json:"id"` // unique; enables idempotent/at-least-once dedupe
Type Type `json:"type"` // the vocabulary
Source string `json:"source"` // module identifier ("graph-cms", "finder", ...)
Subject string `json:"subject"` // the resource that changed (node id, sku, ...)
Payload json.RawMessage `json:"payload,omitempty"` // per-Type body, or nil
Time time.Time `json:"time"`
Meta map[string]string `json:"meta,omitempty"` // trace id, causation, country, dataType, ...
}
// Handler consumes an event. Returning an error means "not handled" — durable
// transports may redeliver; fire-and-forget transports just log.
type Handler func(ctx context.Context, e Event) error