30 Commits
Author SHA1 Message Date
mats c150ac635c fix
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-07-08 18:42:53 +02:00
mats 80a1e6d05e mutation fixes
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
2026-07-08 12:17:36 +02:00
mats 9f1eca07e9 parent item mutations
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 7s
2026-07-08 11:39:42 +02:00
mats e20793a6b3 better results
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
2026-07-08 00:55:05 +02:00
mats 781c1bd171 cart
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-07-05 18:09:55 +02:00
mats e2f835c01b fix
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-07-05 16:08:57 +02:00
mats eed53c96fb fix
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-07-05 14:14:02 +02:00
mats 6180706140 pin PROMOTIONS_FILE on cart deployments
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Cart-actor (x86/arm64) and cart-backoffice all resolve to the same NFS file at nfs/cart-actor/promotions.json given their existing volume mounts. cart-actor pods use PROMOTIONS_FILE=/data/promotions.json (mount subpath); cart-backoffice uses /data/cart-actor/promotions.json (NFS root + CART_DIR). Defensive alignment so UI edits are visible across pods even if a future NFS subpath shuffle happens.
2026-07-04 12:41:36 +02:00
mats 31530be28f promotions
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-07-04 10:51:08 +02:00
mats dddb5c699a same stuff?
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
2026-07-03 23:11:25 +02:00
mats 8bb0a7a786 tests and mutation docs
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 7s
2026-07-03 19:30:02 +02:00
mats b6a67e709d agents md
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-07-03 18:13:37 +02:00
mats cecf4abe76 update
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
2026-07-03 17:45:51 +02:00
mats f339b60351 test more
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
2026-07-02 23:46:00 +02:00
mats 2e2060da5c metrics
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-07-02 10:37:36 +02:00
mats 26fd6dc970 more cart
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
2026-07-01 22:11:37 +02:00
mats 1c97a4bdb0 wishlist and dashboard
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-07-01 13:04:55 +02:00
mats 4a37cb479c more flow
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-07-01 12:13:38 +02:00
mats 83986e7a35 actor
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s
2026-07-01 10:47:28 +02:00
mats b1e99891e9 more cart 2026-07-01 10:40:28 +02:00
mats 75db64ce75 tweak otel
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
2026-06-30 21:11:58 +02:00
mats 9495c654fa link user
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-06-30 18:45:58 +02:00
mats 7b657901ac refactor discovery
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s
2026-06-30 10:44:05 +02:00
mats 2e0204a287 support mapping 2026-06-30 10:31:12 +02:00
mats a469b5ea97 tiny
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-06-29 23:32:28 +02:00
mats d7acfc422c better lock handling
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-06-29 20:10:09 +02:00
mats 98e2fd8d41 some tweaks to cart
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
2026-06-29 20:08:21 +02:00
mats de47ef4f14 ucp
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-06-29 14:32:48 +02:00
mats 46be260fcc refactor
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
2026-06-29 12:34:58 +02:00
matsandClaude Opus 4.8 d711348863 cart: back projection cache with platform/catalog.ProjectionStore
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s
Consumer side of the catalog-projection seam (C1):
- catalogProjectionCache now wraps ProjectionStore[catalog.Projection] (identity
  transform — the cart's overlay uses ~all fields); delivery handler routes bus
  frames via HandleFrame (snapshot begin|chunk|end + epoch-stamped deltas).
- Per-pod cold-start-ready .bin under CART_PROJECTION_DIR (pod-local, never
  shared) + IsDeleted tombstone passthrough; HTTP fetch demoted to last-resort.
- Tests: test-only Apply shim + mustCache helper keep existing cases green;
  new snapshot-framing integration test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:05:55 +02:00
132 changed files with 11024 additions and 1695 deletions
+102
View File
@@ -0,0 +1,102 @@
# go-cart-actor — agents map
The **commercial heart** of the platform. Orleans-style grain actors for cart,
checkout, order, profile, inventory, plus a **UCP (Universal Commerce
Protocol) HTTP adapter** at `internal/ucp/`. Deploys multiple binaries in
parallel (one per concern) coordinated via k8s and an internal actor
discovery/RPC layer. See `docs/checkout-order-handoff.md` for the role split
with `go-order-manager`.
## Where to start
- `cmd/cart/main.go` — the one most commonly run in dev.
- `pkg/actor/` — the grain framework (start with `grain.go`, then
`simple_grain_pool.go` and `grain_pool.go`).
- `pkg/cart/cart-grain.go` — a concrete grain, the cleanest example.
- `internal/ucp/handler.go` and `ucp/{cart,checkout,order,profile}.go`
the protocol adapter over the grains.
- `go-cart-actor/README.md`, `TODO.md`, `NEW_MUTATIONS_SPEC.md` — current
intent and known gaps.
## Architecture
- **Grain model**: each domain entity (cart, order, checkout…) is a grain
keyed by an opaque ID. Per-aggregate state machine lives in the grain's
`state.go`; mutations are individual `mutation_*.go` files under
`pkg/<domain>/` and registered through `mutation_registry.go`.
- **RPC**: gRPC over [`proto/`](proto/) — `cart.proto`, `checkout.proto`,
`order.proto`, `profile.proto`, `control_plane.proto`. Pod discovery via
`pkg/discovery/` + `pkg/proxy/remotehost.go` (k8s host discovery in
`cmd/<bin>/k8s-host-discovery.go`).
- **Binaries** (`cmd/`):
- `cart/`, `checkout/`, `order/`, `profile/`, `inventory/` — one each.
- `backoffice/` — the admin UI host, plugged into `backoffice` via
`pkg/backofficeadmin/`.
- `ucp-artifacts/` — emits the protocol artefacts (signing, handler
bindings).
- **UCP**: `internal/ucp/` is the protocol adapter — it talks to the grains
via RPC, not directly. Separate from `pkg/{cart,checkout,…}`.
- **Cross-cutting**: `pkg/outbox/`, `pkg/idempotency/`, `pkg/flow/`,
`pkg/promotions/`, `pkg/voucher/`, `pkg/telemetry/`.
- **Auth**: `internal/customerauth/` — password hashing (`password.go`),
rate limit, mail notifier, redis store, opaque customer session tokens.
## Important files by area
| Area | File(s) |
| -------------------------- | ------------------------------------------------------ |
| Grain framework | `pkg/actor/{grain,grain_pool,simple_grain_pool,state,disk_storage}.go` |
| Grain RPC | `pkg/actor/{grpc_server,grpc_server_test}.go` |
| Cart mutations | `pkg/cart/mutation_*.go` (+ `cart-grain.go`) |
| Order mutations | `pkg/order/mutation_*.go` + `pkg/order/order-grain.go` |
| Order outbox / payment | `pkg/order/{emit_created,payment,stripe,klarna}_*.go` |
| Promotions / vouchers | `pkg/promotions/`, `pkg/voucher/` |
| Idempotency | `pkg/idempotency/store.go` |
| Outbox (event publish) | `pkg/outbox/outbox.go` |
| UCP protocol adapter | `internal/ucp/{handler,cart,checkout,order,customer,profile}.go`, `signing.go` |
| Backoffice admin | `pkg/backofficeadmin/{app,hub,fileserver,customer}.go` |
| Customer auth | `internal/customerauth/{server,redis,password,tokens,mail_notifier,ratelimit}.go` |
| Telemetry / OTel | `pkg/telemetry/{otel,logs}.go` |
| k8s service discovery | `pkg/discovery/discovery.go`, `pkg/proxy/remotehost.go`, `cmd/<bin>/k8s-host-discovery.go` |
| Tests integration scaffold | `cmd/cart/projection_*_test.go`, `pkg/promotions/apply_test.go` |
## Cross-project seams
- **`platform/`**: uses `platform/auth`, `platform/catalog`, `platform/event`,
`platform/inventory`, `platform/order`, `platform/tax`, `platform/rabbit`.
- **`go-order-manager`**: subscribes to order events off the RabbitMQ
topology (`docs/checkout-order-handoff.md`).
- **`go-shipping`**: pulled via HTTP from `cart` (the cart client).
- **`slask-tracking`**: independent consumer of order events for analytics.
- **`backoffice`** (commerce tag): exposes the `pkg/backofficeadmin/` host.
## Improvement suggestions
- **Mutation file explosion**: each behavior of a grain lives in its own
`mutation_*.go`. This is shallow — the deletion test says removing one just
moves the same code to its sibling file. Consider grouping related
mutations per feature slice (e.g. one `cart_subscription.go` for
add/remove/upsert subscriptiondetails), so the test surface and locality
live together.
- **`pkg/cart` vs `internal/ucp` vs `pkg/checkout`** — three layers touch the
same conceptual "cart" entity. The UCP adapter depends on the grain, the
grain depends on the protocol-specific mutations, and the checkout layer
brokers a different view of the cart. Drop the conceptual split here:
consider promoting one domain (`cart`) as the seam and treating the others
as adapters.
- **Inconsistent layout**: most binaries are at `cmd/<name>/main.go`, but
`internal/ucp/`, `internal/customerauth/`, and `pkg/voucher/`, `pkg/outbox/`
sit at different depths. A short module-layout doc would prevent the next
contributor from guessing where new helpers go.
- **`TODO.md` and `NEW_MUTATIONS_SPEC.md` live in the repo root.** Both belong
in `docs/` so the README is the only thing newcomers hit first.
- **Two proto variants**: `proto/checkout.proto` and
`proto/checkout/checkout.proto`. Likely a historical artefact (see
`proto/README` if it exists, otherwise grep `proto/` for `checkout` to see
which is actually imported).
- **Test coverage is uneven across `pkg/*`** — vendor-friendly modules like
`pkg/promotions/` have `_test.go`, others like `pkg/voucher/` rely on
`parser_test.go` only. Worth a pass to seed unit tests at each public
function entry.
- **`cookies.txt` at repo root** is not a build artefact and shouldn't be
committed.
-2
View File
@@ -20,8 +20,6 @@ func main() {
app, err := backofficeadmin.New(backofficeadmin.Config{
DataDir: config.EnvString("CART_DIR", "data"),
CheckoutDataDir: config.EnvString("CHECKOUT_DIR", "checkout-data"),
RedisAddress: os.Getenv("REDIS_ADDRESS"),
RedisPassword: os.Getenv("REDIS_PASSWORD"),
})
if err != nil {
log.Fatalf("Error creating backoffice: %v", err)
+1 -57
View File
@@ -1,65 +1,9 @@
package main
import (
"log"
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
func GetDiscovery() discovery.Discovery {
if podIp == "" {
return nil
}
config, kerr := rest.InClusterConfig()
if kerr != nil {
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
}
client, err := kubernetes.NewForConfig(config)
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
timeout := int64(30)
// Scope discovery to this pod's namespace so it only needs a namespaced Role
// (pods: get/list/watch), not a cluster-wide ClusterRole.
return discovery.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
LabelSelector: "actor-pool=cart",
TimeoutSeconds: &timeout,
})
}
func UseDiscovery(pool discovery.DiscoveryTarget) {
go func(hw discovery.Discovery) {
if hw == nil {
log.Print("No discovery service available")
return
}
ch, err := hw.Watch()
if err != nil {
log.Printf("Discovery error: %v", err)
return
}
for evt := range ch {
if evt.Host == "" {
continue
}
switch evt.IsReady {
case false:
if pool.IsKnown(evt.Host) {
log.Printf("Host %s is not ready, removing", evt.Host)
pool.RemoveHost(evt.Host)
}
default:
if !pool.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
pool.AddRemoteHost(evt.Host)
}
}
}
}(GetDiscovery())
discovery.StartK8sDiscovery(podIp, "actor-pool=cart", 30, pool)
}
+196 -194
View File
@@ -11,13 +11,13 @@ import (
"os"
"os/signal"
"strings"
"sync"
"time"
"git.k6n.net/mats/go-cart-actor/internal/ucp"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp"
"git.k6n.net/mats/go-cart-actor/pkg/cart/recovery"
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
@@ -27,19 +27,17 @@ import (
"git.k6n.net/mats/platform/config"
"git.k6n.net/mats/platform/event"
"git.k6n.net/mats/platform/rabbit"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
var (
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grain_spawned_total",
Help: "The total number of spawned grains",
})
// cartMetrics is the shared prometheus instrumentation for the
// cart actor pool and event log. Built once at process start; nil
// is not expected in production but the actor package's
// touch sites are nil-safe so a test binary can pass nil.
cartMetrics = actor.NewMetrics("cart")
)
func init() {
@@ -105,136 +103,60 @@ type CartChangeEvent struct {
Mutations []actor.ApplyResult `json:"mutations"`
}
func matchesSkuAndLocation(update inventory.InventoryResult, item cart.CartItem) bool {
if string(update.SKU) == item.Sku {
if update.LocationID == "se" && item.StoreId == nil {
return true
}
if item.StoreId == nil {
return false
}
if *item.StoreId == string(update.LocationID) {
return true
}
}
return false
}
// catalogProjectionCache is the cart's in-process map of SKU → catalog.Projection,
// updated by reading catalog.projection_published events off the bus. It is the
// cart-side source of truth for catalog facts (price, tax_class, inventory_tracked
// flag, currency, image, brand) used at add-to-cart / checkout-reserve decisions.
// Stock state is NOT cached here — exact qty is still queried synchronously from
// the inventory service at decision points per docs/inventory-shape.md.
// catalogProjectionCache is the cart's per-pod, cold-start-ready catalog
// projection cache, backed by platform/catalog.ProjectionStore. It consumes the
// catalog.projection_published wire — a full snapshot (begin/chunk/end, framed by
// epoch) plus incremental deltas — into its OWN local .bin and serves catalog
// facts by SKU (price, tax_class, inventory_tracked, image, …) at add-to-cart /
// checkout-reserve decisions. Stock is NOT cached — queried synchronously from
// inventory per docs/inventory-shape.md.
//
// Concurrency: read-mostly workload justifies sync.RWMutex.
// Clustering: the cart runs N replicas. Each pod has its OWN exclusive bus queue
// (rabbit.ListenToPattern declares an exclusive server-named queue, so every pod
// receives the full stream) and its OWN pod-local .bin. It's a replicated read
// cache — no leader, no shared volume; N pods writing one file would corrupt it.
// See docs/catalog-projection.md.
//
// Single-tier storage. The bus is the only writer — the cart does not separately
// seed from a snapshot file at boot, because the access pattern is point lookup
// of cart-active SKUs only, and coupling two sources (mmap snapshot + bus-fed
// map) created duplicated state with subtle drift risk and an mmap-SIGBUS hazard.
// Cold-grace: the cache may be incomplete for an SKU the cart sees before the
// first projection_published arrives for it; the cart falls back to the existing
// PRODUCT_BASE_URL HTTP fetch and overlays the cache fields on top of it.
//
// Deletes carry a tombstone in the map (not a plain delete) so a bus delete of
// an SKU that already fits the cache rules correctly shadows it for Get /
// IsDeleted lookups — preventing an oversight from re-fetching a deleted SKU
// before the cache rebuilds. Tombstones have a per-pod lifetime; bus-driven,
// so the next sweep on the same SKU flips the entry back to live.
// The cart stores the full catalog.Projection (identity transform): its
// add-to-cart overlay (projection_overlay.go) consumes nearly every field, so
// there's nothing to subset. Deletes ride as tombstones in the store's overlay
// (IsDeleted) so a removed SKU isn't re-fetched before the next snapshot folds
// the delete into the base. Cold-grace: an SKU not yet in the base/overlay
// resolves via the existing PRODUCT_BASE_URL HTTP fetch + overlay.
type catalogProjectionCache struct {
mu sync.RWMutex
items map[string]projectionEntry
store *catalog.ProjectionStore[catalog.Projection]
}
// projectionEntry is a cache slot: a live projection, or a tombstone (deleted)
// that shadows the live fallback.
type projectionEntry struct {
proj catalog.Projection
deleted bool
}
// identityProjection is the cart's mandatory transform — it stores the full
// Projection because the overlay uses almost all of it.
func identityProjection(p catalog.Projection) catalog.Projection { return p }
func newCatalogProjectionCache() *catalogProjectionCache {
return &catalogProjectionCache{items: make(map[string]projectionEntry, 8192)}
}
func (c *catalogProjectionCache) Apply(updates []catalog.ProjectionUpdate) (upserts, deletes int) {
c.mu.Lock()
defer c.mu.Unlock()
for _, u := range updates {
if u.Deleted {
c.items[u.SKU] = projectionEntry{deleted: true}
deletes++
continue
}
if u.SKU == "" {
continue
}
c.items[u.SKU] = projectionEntry{proj: u.Projection}
upserts++
}
return
}
func (c *catalogProjectionCache) Get(sku string) (catalog.Projection, bool) {
c.mu.RLock()
e, ok := c.items[sku]
c.mu.RUnlock()
if !ok {
return catalog.Projection{}, false
}
if e.deleted {
return catalog.Projection{}, false
}
return e.proj, true
}
// IsDeleted reports whether a tombstone exists for sku — used by HTTP-fetch
// call sites to skip the fallback for an SKU the bus has explicitly deleted,
// avoiding a wasteful round-trip on items the writer has just removed.
func (c *catalogProjectionCache) IsDeleted(sku string) bool {
c.mu.RLock()
e, ok := c.items[sku]
c.mu.RUnlock()
return ok && e.deleted
}
// Len reports the number of map entries (bus-fed upserts + tombstones). Used
// for log gauges only.
func (c *catalogProjectionCache) Len() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.items)
}
// projectionDeliveryHandler is the AMQP delivery handler for
// `catalog.projection_published` on the `catalog` topic exchange. Decodes the
// []ProjectionUpdate batch and merges it into cache. Returns nil on a batch
// that doesn't apply locally so the AMQP delivery is acked; returns an error
// on a decode failure so the broker knows to redeliver / quarantine.
//
// Recovery: any panic in a single event is caught so the goroutine keeps
// draining the queue; the bus stays live across malformed events.
func projectionDeliveryHandler(cache *catalogProjectionCache) func(amqp.Delivery) error {
return func(d amqp.Delivery) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("projection handler panic recovered: %v", r)
}
}()
var ev event.Event
if err := json.Unmarshal(d.Body, &ev); err != nil {
return fmt.Errorf("decode event envelope: %w", err)
}
updates, err := catalog.DecodeUpdates(ev.Payload)
// newCatalogProjectionCache opens the per-pod store under dir. dir MUST be
// pod-local (container fs / an emptyDir in k8s) — never a shared volume.
func newCatalogProjectionCache(dir string) (*catalogProjectionCache, error) {
s, err := catalog.NewProjectionStore(dir, "cart-projection.bin", identityProjection, nil)
if err != nil {
return fmt.Errorf("decode projection updates: %w", err)
return nil, err
}
upserts, deletes := cache.Apply(updates)
log.Printf("cart: catalog projection batch applied: upserts=%d deletes=%d total=%d eventID=%s source=%q",
upserts, deletes, cache.Len(), ev.ID, ev.Source)
return nil
return &catalogProjectionCache{store: s}, nil
}
// HandleFrame feeds one catalog.projection_published event (snapshot frame or
// delta) into the store; framing/epoch ordering live in platform/catalog.
func (c *catalogProjectionCache) HandleFrame(meta map[string]string, payload []byte) error {
return c.store.HandleFrame(meta, payload)
}
func (c *catalogProjectionCache) Get(sku string) (catalog.Projection, bool) { return c.store.Get(sku) }
// IsDeleted reports whether the bus has explicitly deleted sku (overlay
// tombstone) — call sites skip the HTTP fallback for it.
func (c *catalogProjectionCache) IsDeleted(sku string) bool { return c.store.IsDeleted(sku) }
// Len reports cached entries (base + overlay) — debug/log gauge only.
func (c *catalogProjectionCache) Len() int {
_, base, overlay := c.store.Stats()
return base + overlay
}
func main() {
@@ -249,7 +171,11 @@ func main() {
controlPlaneConfig := actor.DefaultServerConfig()
promotionStore, err := promotions.NewStore("data/promotions.json")
promotionsPath := os.Getenv("PROMOTIONS_FILE")
if promotionsPath == "" {
promotionsPath = "data/promotions.json"
}
promotionStore, err := promotions.NewStore(promotionsPath)
if err != nil {
log.Fatalf("Error loading promotions: %v\n", err)
}
@@ -265,88 +191,42 @@ func main() {
Password: redisPassword,
DB: 0,
})
inventoryService, err := inventory.NewRedisInventoryService(rdb)
if err != nil {
log.Fatalf("Error creating inventory service: %v\n", err)
}
_ = inventoryService
inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
if err != nil {
log.Fatalf("Error creating inventory reservation service: %v\n", err)
}
reservationPolicy := inventory.NewReservationPolicyAdapter(inventoryReservationService)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(inventoryReservationService))
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(reservationPolicy))
reg.RegisterProcessor(
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
_, span := tracer.Start(ctx, "Totals and promotions")
defer span.End()
// Clear bypass flags first
for _, v := range g.Vouchers {
if v != nil {
v.BypassedByPromotions = false
}
}
g.UpdateTotals()
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
// Check if any qualified promotion rule has a coupon_code condition matching our vouchers
hasBypassed := false
for _, res := range results {
if res.Applicable {
promotions.WalkConditions(res.Rule.Conditions, func(c promotions.Condition) bool {
if bc, ok := c.(promotions.BaseCondition); ok && bc.Type == promotions.CondCouponCode {
var codes []string
if s, ok := bc.Value.AsString(); ok {
codes = append(codes, strings.ToLower(s))
} else if arr, ok := bc.Value.AsStringSlice(); ok {
for _, s := range arr {
codes = append(codes, strings.ToLower(s))
}
}
for _, code := range codes {
for _, v := range g.Vouchers {
if v != nil && strings.ToLower(v.Code) == code {
if !v.BypassedByPromotions {
v.BypassedByPromotions = true
hasBypassed = true
}
}
}
}
}
return true
})
}
}
if hasBypassed {
// Re-evaluate with bypassed vouchers
g.UpdateTotals()
promotionCtx = promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
results, _ = promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
}
// ApplyResults applies qualifying actions in priority order and records
// every effect — both applied discounts and pending "spend X more for ..."
// nudges with their progress — in g.AppliedPromotions.
promotionService.ApplyResults(g, results, promotionCtx)
// Canonical promotion pipeline — same call site as the
// /promotions/evaluate-with-cart preview handler in
// promotions_evaluate.go. Going through the shared
// PromotionService.EvaluateAndApply means the bypass
// loop, the re-eval trigger, and ApplyResults stay in
// lockstep with the preview so a what-if always matches
// post-add behavior (including the coupon+promotion-
// overlap edge case). If you find yourself adding
// promotion logic here, add it to EvaluateAndApply
// instead — the preview must follow.
promotionService.EvaluateAndApply(promotionStore.Snapshot(), g, promotions.WithNow(time.Now()))
g.Version++
return nil
}),
)
diskStorage := actor.NewDiskStorage[cart.CartGrain](config.EnvString("CART_DIR", "data"), reg)
diskStorage.SetMetrics(cartMetrics)
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
MutationRegistry: reg,
Storage: diskStorage,
Metrics: cartMetrics,
Spawn: func(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn cart id %d", id))
defer span.End()
grainSpawns.Inc()
ret := cart.NewCartGrain(id, time.Now())
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
@@ -423,7 +303,13 @@ func main() {
// connection keeps the cart's mutation-feed lifecycle decoupled from a
// read-write-consume connection that could grow event handlers later
// (e.g. promotions reacting to a price drop on a watched SKU).
projectionCache := newCatalogProjectionCache()
// Pod-local store dir — container fs / an emptyDir in k8s. NEVER a shared
// volume: each replica owns its own .bin (replicated read cache, no leader).
projectionDir := config.EnvString("CART_PROJECTION_DIR", "data/projection")
projectionCache, err := newCatalogProjectionCache(projectionDir)
if err != nil {
log.Fatalf("Error opening catalog projection cache: %v", err)
}
if amqpUrl != "" {
if pConn, perr := rabbit.Dial(amqpUrl, "cart-catalog-projection"); perr != nil {
log.Printf("cart: catalog projection consumer DISABLED (dial failed): %v", perr)
@@ -434,7 +320,7 @@ func main() {
log.Printf("cart: channel on projection reconnect: %v", err)
return
}
if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogProjectionPublished), projectionDeliveryHandler(projectionCache)); err != nil {
if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogProjectionPublished), catalog.MakeAmqpHandler(projectionCache.store)); err != nil {
log.Printf("cart: bind catalog.projection_published on reconnect: %v", err)
_ = ch.Close()
}
@@ -460,12 +346,71 @@ func main() {
}
defer grpcSrv.GracefulStop()
// go diskStorage.SaveLoop(10 * time.Second)
go diskStorage.SaveLoop(20 * time.Second)
UseDiscovery(pool)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
// Data Retention (C5) GDPR Purge
retentionDays := config.EnvInt("CART_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 })
if retentionDays > 0 {
purgeInterval := config.EnvDuration("CART_PURGE_INTERVAL", 24*time.Hour)
log.Printf("cart: data retention enabled. Retaining carts for %d days, purging every %v", retentionDays, purgeInterval)
go func() {
ticker := time.NewTicker(purgeInterval)
defer ticker.Stop()
// Run immediately on startup
runPurge(ctx, diskStorage, pool, retentionDays)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runPurge(ctx, diskStorage, pool, retentionDays)
}
}
}()
} else {
log.Print("cart: data retention / GDPR purge disabled (CART_RETENTION_DAYS not set or <= 0)")
}
// Abandoned-cart recovery check (C3-notifications seam, dry-run by default).
// Scans local pod event-log files for carts whose modtime falls into the
// "[now - delay - window, now - delay]" window and that have items + a
// contact (email and/or push tokens). Hands each candidate to the
// configured Notifier (LoggingNotifier v0). The scanner reads local shard
// files only — the control plane doesn't fan out between pods on purpose
// (each pod owns the carts it is the owner of). Idempotency is the next
// pass; the v0 window size keeps duplicate notifications minute-scoped.
abandonedDelay := config.EnvDuration("CART_ABANDONED_DELAY", time.Hour)
if abandonedDelay > 0 {
abandonedWindow := config.EnvDuration("CART_ABANDONED_WINDOW", time.Hour)
abandonedInterval := config.EnvDuration("CART_ABANDONED_SCAN_INTERVAL", 15*time.Minute)
notifier := recovery.NewLoggingNotifier()
scanner := recovery.NewScanner(config.EnvString("CART_DIR", "data"), diskStorage)
log.Printf("cart: abandonment recovery ENABLED. Delay=%v window=%v scan_interval=%v (notifier=LoggingNotifier dry-run)", abandonedDelay, abandonedWindow, abandonedInterval)
go func() {
ticker := time.NewTicker(abandonedInterval)
defer ticker.Stop()
runRecovery(ctx, scanner, notifier, abandonedDelay, abandonedWindow)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runRecovery(ctx, scanner, notifier, abandonedDelay, abandonedWindow)
}
}
}()
} else {
log.Print("cart: abandonment recovery DISABLED (CART_ABANDONED_DELAY not set or <= 0)")
}
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
if err != nil {
log.Fatalf("Unable to start otel %v", err)
@@ -503,6 +448,19 @@ func main() {
// without creating or mutating a real cart.
mux.HandleFunc("POST /promotions/evaluate", newPromotionEvaluateHandler(promotionStore, promotionService))
// Cart-aware promotion evaluation: reads the cartid cookie the storefront
// already maintains, fetches the actual cart grain (cross-pod via
// GetAnywhere), merges with the request's items (the items the user is
// considering adding), and runs the engine on the merged line list. The
// cart is the source of truth (vouchers, customer segment, customer
// lifetime value, total) so a serialised copy from the browser can race
// the live cart — letting the backend read the cookie + fetch the grain
// means exactly one source of truth and no race. Missing cookie or fetch
// failure gracefully degrades to a stateless evaluation of the request
// items alone. See newPromotionEvaluateWithCartHandler in
// promotions_evaluate.go for the full contract.
mux.HandleFunc("POST /promotions/evaluate-with-cart", newPromotionEvaluateWithCartHandler(promotionStore, promotionService, syncedServer))
// only for local
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
pool.AddRemote(r.PathValue("host"))
@@ -612,3 +570,47 @@ func main() {
}
}
func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[cart.CartGrain], pool *actor.SimpleGrainPool[cart.CartGrain], retentionDays int) {
log.Printf("cart: starting data retention purge...")
retention := time.Duration(retentionDays) * 24 * time.Hour
localIds := make(map[uint64]bool)
for _, id := range pool.GetLocalIds() {
localIds[id] = true
}
isGrainActive := func(id uint64) bool {
return localIds[id]
}
purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive)
if err != nil {
log.Printf("cart: data retention purge failed: %v", err)
} else {
log.Printf("cart: data retention purge completed. Purged %d expired cart logs", purged)
}
}
// runRecovery is the abandoned-cart scanner pass. It finds carts that crossed
// the abandonment threshold in the recent window and hands each to the
// configured Notifier. Each pass is independent; idempotency across ticks is
// the next pass (logged duplicates are not catastrophic for the dry-run
// notifier, but a real MailerSend/FCM impl will need a SETNX side-key).
func runRecovery(ctx context.Context, scanner *recovery.Scanner, notifier recovery.Notifier, delay, window time.Duration) {
candidates, err := scanner.Scan(ctx, delay, window, time.Now())
if err != nil {
log.Printf("cart: recovery scan failed: %v", err)
return
}
if len(candidates) == 0 {
log.Printf("cart: recovery scan complete, 0 candidates (window delay=%v width=%v)", delay, window)
return
}
log.Printf("cart: recovery scan complete, %d candidates", len(candidates))
for _, c := range candidates {
if err := notifier.NotifyAbandoned(ctx, c); err != nil {
log.Printf("cart: recovery notify (cart %d) failed: %v", c.CartID, err)
}
}
}
+69 -14
View File
@@ -16,8 +16,6 @@ import (
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
@@ -28,16 +26,13 @@ import (
"go.opentelemetry.io/otel/trace"
)
var (
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grain_mutations_total",
Help: "The total number of mutations",
})
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grain_lookups_total",
Help: "The total number of lookups",
})
)
// Pool metrics (cart_grain_spawned_total, cart_grain_lookups_total,
// cart_mutations_total, cart_remote_negotiation_total,
// cart_connected_remotes, …) are bumped centrally by SimpleGrainPool
// — see pkg/actor/simple_grain_pool.go. The cart Metrics is
// registered once in cmd/cart/main.go and shared with DiskStorage
// via SetMetrics, so the per-handler counters this file used to
// maintain are no longer needed.
type PoolServer struct {
actor.GrainPool[cart.CartGrain]
@@ -76,6 +71,9 @@ func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id c
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
sku := r.PathValue("sku")
country := getCountryFromHost(r.Host)
if s.idx == nil {
log.Printf("No index present")
}
if s.idx != nil && s.idx.IsDeleted(sku) {
// Bus-deleted SKU — skip the HTTP fetch and reject the add. Mirrors the
// product-fetcher's "product service returned %d for sku %s" shape so
@@ -100,6 +98,9 @@ func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request,
}
}
if msg == nil {
if s.idx == nil {
log.Printf("No item found in index %s", sku)
}
var err error
msg, err = GetItemAddMessage(r.Context(), sku, 1, country, nil)
if err != nil {
@@ -116,10 +117,17 @@ func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request,
if err != nil {
return err
}
grainMutations.Add(float64(len(data.Mutations)))
return s.WriteResult(w, data)
}
// Pool metrics (cart_grain_spawned_total, cart_grain_lookups_total,
// cart_mutations_total, cart_remote_negotiation_total,
// cart_connected_remotes, …) are bumped centrally by SimpleGrainPool
// — see pkg/actor/simple_grain_pool.go. The cart Metrics is
// registered once in cmd/cart/main.go and shared with DiskStorage
// via SetMetrics, so the per-handler counters this file used to
// maintain are no longer needed.
func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
@@ -492,7 +500,6 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
grainLookups.Inc()
if err == nil && handled {
return nil
}
@@ -599,6 +606,49 @@ func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request
return nil
}
// SetRecoveryContactRequest is the wire shape clients send to
// PUT /cart/recovery-contact. It mirrors proto SetRecoveryContact with
// JSON-friendly types; sending an empty JSON object clears all contact info.
type SetRecoveryContactRequest struct {
Email string `json:"email,omitempty"`
PushTokens []cart.PushToken `json:"pushTokens,omitempty"`
}
// toMessage builds the proto message from the JSON request body.
func (s *SetRecoveryContactRequest) toMessage() *messages.SetRecoveryContact {
out := &messages.SetRecoveryContact{Email: s.Email}
if len(s.PushTokens) > 0 {
out.PushTokens = make([]*messages.PushToken, 0, len(s.PushTokens))
for _, t := range s.PushTokens {
out.PushTokens = append(out.PushTokens, &messages.PushToken{
Platform: t.Platform,
Token: t.Token,
})
}
}
return out
}
func (s *PoolServer) SetRecoveryContactHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
return nil
}
req := SetRecoveryContactRequest{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, req.toMessage())
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) SetUserIdHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
setUserId := messages.SetUserId{}
err := json.NewDecoder(r.Body).Decode(&setUserId)
@@ -741,6 +791,10 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
handleFunc("PUT /cart/user", CookieCartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
// PUT only — SetRecoveryContact is PUT/replace semantics. We deliberately
// do NOT allow POST here to avoid conflicts with any future POST→create
// semantics on /cart paths.
handleFunc("PUT /cart/recovery-contact", CookieCartIdHandler(s.ProxyHandler(s.SetRecoveryContactHandler)))
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
@@ -757,6 +811,7 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
handleFunc("PUT /cart/byid/{id}/recovery-contact", CartIdHandler(s.ProxyHandler(s.SetRecoveryContactHandler)))
handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
handleFunc("PUT /cart/byid/{id}/item/{itemId}/custom-fields", CartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler)))
+206
View File
@@ -0,0 +1,206 @@
package main
import (
"encoding/json"
"net/http/httptest"
"testing"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
)
// TestWriteResult_CartGrainCarriesEvaluatedItems verifies that the per-line
// promotion breakdown lives on CartGrain.EvaluatedItems (so the canonical
// promotion pipeline populates it once per mutation, not at every read
// path) and naturally serialises through WriteResult alongside the rest
// of the grain's tagged fields. There is no envelope wrapper anymore —
// the grain's own JSON tag carries the field. See pkg/cart/evaluated_item.go
// for EvaluatedItem semantics and pkg/cart/cart-grain.go for the EvaluatedItems
// field declaration.
func TestWriteResult_CartGrainCarriesEvaluatedItems(t *testing.T) {
grain := &cart.CartGrain{
Id: 1,
Currency: "SEK",
Items: []*cart.CartItem{
{
Sku: "promo-1",
Quantity: 2,
Price: *cart.NewPriceFromIncVat(10000, 2500),
Discount: cart.NewPriceFromIncVat(2000, 500),
Meta: &cart.ItemMeta{Name: "Promo Item"},
},
},
TotalPrice: cart.NewPriceFromIncVat(20000, 5000),
TotalDiscount: cart.NewPriceFromIncVat(4000, 1000),
EvaluatedItems: []cart.EvaluatedItem{{
Sku: "promo-1",
Name: "Promo Item",
Quantity: 2,
PriceIncVat: 10000,
OrgPriceIncVat: 12000,
DiscountIncVat: 2000,
EffectivePriceIncVat: 9000,
EffectiveTotalIncVat: 18000,
}},
}
s := &PoolServer{}
w := httptest.NewRecorder()
if err := s.WriteResult(w, grain); err != nil {
t.Fatalf("WriteResult: %v", err)
}
var resp map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v (body=%q)", err, w.Body.String())
}
for _, key := range []string{"id", "currency", "items", "totalPrice", "totalDiscount", "evaluatedItems"} {
if _, ok := resp[key]; !ok {
t.Fatalf("expected top-level %q in grain serialisation, got keys %v", key, mapKeys(resp))
}
}
var ev []struct {
Sku string `json:"sku"`
DiscountIncVat int64 `json:"discountIncVat"`
EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"`
EffectivePriceIncVat int64 `json:"effectivePriceIncVat"`
}
if err := json.Unmarshal(resp["evaluatedItems"], &ev); err != nil {
t.Fatalf("evaluatedItems parse: %v", err)
}
if len(ev) != 1 || ev[0].Sku != "promo-1" {
t.Fatalf("expected one promo-1 evaluated item, got %+v", ev)
}
if ev[0].DiscountIncVat != 2000 || ev[0].EffectiveTotalIncVat != 18000 || ev[0].EffectivePriceIncVat != 9000 {
t.Fatalf("evaluatedItem math off: %+v", ev[0])
}
}
// TestWriteResult_MutationResultNestsEvaluatedItems verifies the natural
// post-mutation wire shape: actor.MutationResult[cart.CartGrain] serialises
// Result (the grain) and Mutations (applied-change metadata) at the existing
// top-level positions, and CartGrain's EvaluatedItems rides inside Result
// because the grain owns the field. Previously the envelope wrapper pulled
// evaluatedItems out as a sibling of {result, mutations}; the natural
// nesting is what the user wanted ("no strange patterns on a global level").
// Internal/admin tools that read response.evaluatedItems will need to read
// response.result.evaluatedItems instead. Wrap-around breakage acknowledged.
func TestWriteResult_MutationResultNestsEvaluatedItems(t *testing.T) {
grain := &cart.CartGrain{
Id: 1,
Currency: "SEK",
Items: []*cart.CartItem{
{
Sku: "promo-1",
Quantity: 1,
Price: *cart.NewPriceFromIncVat(10000, 2500),
Discount: cart.NewPriceFromIncVat(3000, 750),
},
},
EvaluatedItems: []cart.EvaluatedItem{{
Sku: "promo-1",
Quantity: 1,
PriceIncVat: 10000,
DiscountIncVat: 3000,
EffectiveTotalIncVat: 7000,
EffectivePriceIncVat: 7000,
}},
}
mr := &actor.MutationResult[cart.CartGrain]{
Result: *grain,
Mutations: []actor.ApplyResult{{Type: "cart.AddItem"}},
}
s := &PoolServer{}
w := httptest.NewRecorder()
if err := s.WriteResult(w, mr); err != nil {
t.Fatalf("WriteResult: %v", err)
}
var resp map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// {result, mutations} are the existing envelope — preserved.
for _, key := range []string{"result", "mutations"} {
if _, ok := resp[key]; !ok {
t.Fatalf("expected top-level %q in mutation response, got %v", key, mapKeys(resp))
}
}
// evaluatedItems no longer rises to the top level (the envelope is
// gone). Confirm absence at the top — it's now nested under result.
if _, hasTop := resp["evaluatedItems"]; hasTop {
t.Fatalf("evaluatedItems leaked to top level — envelope wrapper returned somehow")
}
// Result nests the grain, including EvaluatedItems naturally.
var inner map[string]json.RawMessage
if err := json.Unmarshal(resp["result"], &inner); err != nil {
t.Fatalf("result parse: %v", err)
}
if _, ok := inner["items"]; !ok {
t.Fatalf("result.items missing — Result did not carry the grain")
}
evRaw, ok := inner["evaluatedItems"]
if !ok {
t.Fatalf("result.evaluatedItems missing — grain field should nest under result")
}
var ev []struct {
Sku string `json:"sku"`
DiscountIncVat int64 `json:"discountIncVat"`
}
if err := json.Unmarshal(evRaw, &ev); err != nil {
t.Fatalf("result.evaluatedItems parse: %v", err)
}
if len(ev) != 1 || ev[0].Sku != "promo-1" || ev[0].DiscountIncVat != 3000 {
t.Fatalf("result.evaluatedItems wrong: %+v", ev)
}
// Mutations are still a sibling of result.
var muts []map[string]any
if err := json.Unmarshal(resp["mutations"], &muts); err != nil {
t.Fatalf("mutations parse: %v", err)
}
if len(muts) != 1 || muts[0]["type"] != "cart.AddItem" {
t.Fatalf("mutations wrong: %+v", muts)
}
}
// TestWriteResult_NoEvaluatedItemsOmitsField verifies the EvaluatedItems
// omitempty on CartGrain drops the field entirely when the canonical
// pipeline hasn't run (synthetic grain in a fixture, fresh spawn before
// any promotion-aware mutation). Clients reading evaluatedItems can
// rely on absence to mean "no promotion compute yet".
func TestWriteResult_NoEvaluatedItemsOmitsField(t *testing.T) {
grain := &cart.CartGrain{
Id: 1,
Currency: "SEK",
}
s := &PoolServer{}
w := httptest.NewRecorder()
if err := s.WriteResult(w, grain); err != nil {
t.Fatalf("WriteResult: %v", err)
}
var resp map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, has := resp["evaluatedItems"]; has {
t.Fatalf("expected evaluatedItems omitted on a grain with nil breakdown")
}
}
// mapKeys returns the sorted set of top-level keys for nicer failure output.
func mapKeys(m map[string]json.RawMessage) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
+1 -1
View File
@@ -139,7 +139,7 @@ const minGlassArea = 0.4 // m²
// areaFromItem derives the main article's *visible glass* surface in m² from a
// resolved configurator selection. The catalog width/height are facet codes;
// the outer (karmytter) size is `code × 100 margin` mm and the visible glass
// is that minus the frame border per axis, e.g. width 4, widthMargin 20,
// is that minus the frame border per axis, e.g. width 4, marginWidth 20,
// borderWidth 194 → 400 20 194 = 186 mm. Area = glassW(mm) × glassH(mm) /
// 1e6, clamped up to the minGlassArea billing floor. Returns 0 when a dimension
// is missing/non-numeric (non-window PDP, or no product resolved yet).
+6 -6
View File
@@ -10,7 +10,7 @@ import (
// TestProjectionCache_ApplyUpserts covers the happy path: a batch of N
// projections reaches the cache via Apply, all are visible via Get.
func TestProjectionCache_ApplyUpserts(t *testing.T) {
c := newCatalogProjectionCache()
c := mustCache(t)
updates := []catalog.ProjectionUpdate{
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00), TaxClass: "standard"}},
{Projection: catalog.Projection{ID: "id-B", SKU: "B", PriceIncVat: money.Cents(150_00), TaxClass: "reduced"}},
@@ -48,7 +48,7 @@ func TestProjectionCache_ApplyUpserts(t *testing.T) {
// - IsDeleted returns true
// - the same entry continues to count toward Len (size accounting)
func TestProjectionCache_DeleteTombstone(t *testing.T) {
c := newCatalogProjectionCache()
c := mustCache(t)
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00)}},
})
@@ -81,7 +81,7 @@ func TestProjectionCache_DeleteTombstone(t *testing.T) {
// clears the tombstone (live entry replaces it): Get returns the projection,
// IsDeleted flips to false. Mirrors how the live catalog keeps flipping state.
func TestProjectionCache_BusResurrect(t *testing.T) {
c := newCatalogProjectionCache()
c := mustCache(t)
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(50_00)}},
{Projection: catalog.Projection{SKU: "A"}, Deleted: true},
@@ -110,7 +110,7 @@ func TestProjectionCache_BusResurrect(t *testing.T) {
// SKU is a no-op (defensive — bus should never publish "" but cheaper to log
// + drop than to populate an ambiguous cache slot).
func TestProjectionCache_EmptySKUIgnored(t *testing.T) {
c := newCatalogProjectionCache()
c := mustCache(t)
upserts, _ := c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "", PriceIncVat: money.Cents(100_00)}},
})
@@ -125,7 +125,7 @@ func TestProjectionCache_EmptySKUIgnored(t *testing.T) {
// TestProjectionCache_BusRaceSafe runs Apply + Get concurrently under -race
// to lock down the lock order. Run via `go test -race ./cmd/cart/...`.
func TestProjectionCache_BusRaceSafe(t *testing.T) {
c := newCatalogProjectionCache()
c := mustCache(t)
done := make(chan struct{})
go func() {
defer close(done)
@@ -151,7 +151,7 @@ func TestProjectionCache_BusRaceSafe(t *testing.T) {
// the per-batch counters report the right split. Without this the cache could
// drift on count semantics across batched events.
func TestApply_MixedUpsertDelete(t *testing.T) {
c := newCatalogProjectionCache()
c := mustCache(t)
// Order matters: prior in-cache state for A is upserted twice (latest wins),
// B is tombstoned, C cold-upserted, D cold-tombstoned.
updates := []catalog.ProjectionUpdate{
+7 -7
View File
@@ -142,7 +142,7 @@ func TestApplyProjectionOverlay_NilMsgSafe(t *testing.T) {
// bypass the tombstone branch and fall through to the HTTP fetch — failing
// the test for a wiring reason rather than the contract under test.
func TestAddSkuToCartHandler_TombstoneReturns404(t *testing.T) {
idx := newCatalogProjectionCache()
idx := mustCache(t)
idx.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "DEL", PriceIncVat: money.Cents(10_00)}},
{Projection: catalog.Projection{SKU: "DEL"}, Deleted: true},
@@ -181,7 +181,7 @@ func TestAddSkuToCartHandler_TombstoneReturns404(t *testing.T) {
// TestIsDeletedVsGet asserts the two methods don't conflict: a tombstoned SKU
// is reported by both IsDeleted (true) and Get (miss).
func TestIsDeletedVsGet(t *testing.T) {
c := newCatalogProjectionCache()
c := mustCache(t)
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "DL", PriceIncVat: money.Cents(10_00)}},
{Projection: catalog.Projection{SKU: "DL"}, Deleted: true},
@@ -395,7 +395,7 @@ func TestBuildItemGroups_CacheOnlySkipsHTTP(t *testing.T) {
prod := newFakeProductServer(t)
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
idx := newCatalogProjectionCache()
idx := mustCache(t)
idx.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{
SKU: "TOP",
@@ -463,7 +463,7 @@ func TestBuildItemGroups_HTTPFallbackWhenChildrenPresent(t *testing.T) {
prod := newFakeProductServer(t)
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
idx := newCatalogProjectionCache()
idx := mustCache(t)
idx.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{
SKU: "PARENT", PriceIncVat: money.Cents(99_00),
@@ -518,7 +518,7 @@ func TestAddSkuToCartHandler_CacheOnlySkipsHTTP(t *testing.T) {
defer failSrv.Close()
t.Setenv("PRODUCT_BASE_URL", failSrv.URL)
idx := newCatalogProjectionCache()
idx := mustCache(t)
idx.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{
SKU: "OK", PriceIncVat: money.Cents(99_00),
@@ -550,7 +550,7 @@ func TestBuildItemGroups_HTTPFallbackWhenCacheMiss(t *testing.T) {
prod := newFakeProductServer(t)
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
idx := newCatalogProjectionCache() // empty
idx := mustCache(t) // empty
groups := buildItemGroups(context.Background(), []Item{
{Sku: "MISS", Quantity: 1},
}, "se", idx)
@@ -575,7 +575,7 @@ func TestBuildItemGroups_HTTPFallbackWhenHasRequiredFieldsFalse(t *testing.T) {
prod := newFakeProductServer(t)
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
idx := newCatalogProjectionCache()
idx := mustCache(t)
idx.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{
SKU: "PARTIAL", PriceIncVat: money.Cents(99_00),
@@ -0,0 +1,72 @@
package main
import (
"strconv"
"testing"
"git.k6n.net/mats/platform/catalog"
"git.k6n.net/mats/platform/money"
)
// frameMeta builds the event.Meta a producer stamps for a snapshot frame / delta.
func frameMeta(phase string, epoch int64) map[string]string {
m := map[string]string{catalog.MetaEpoch: strconv.FormatInt(epoch, 10), catalog.MetaTenant: "se"}
if phase != "" {
m[catalog.MetaSnapshotPhase] = phase
}
return m
}
func enc(t *testing.T, ups []catalog.ProjectionUpdate) []byte {
t.Helper()
b, err := catalog.EncodeUpdates(ups)
if err != nil {
t.Fatalf("encode: %v", err)
}
return b
}
// TestCartCache_SnapshotThenDelta exercises the cart's cache through the real bus
// framing: a full begin→chunk→end snapshot makes SKUs Get-able cold (no Apply),
// then an epoch-matched delta overrides one and tombstones another.
func TestCartCache_SnapshotThenDelta(t *testing.T) {
c := mustCache(t)
const epoch = int64(1000)
if err := c.HandleFrame(frameMeta(catalog.SnapshotBegin, epoch), enc(t, nil)); err != nil {
t.Fatal(err)
}
if err := c.HandleFrame(frameMeta(catalog.SnapshotChunk, epoch), enc(t, []catalog.ProjectionUpdate{
{Projection: catalog.Projection{ID: "sku:A", SKU: "A", PriceIncVat: money.Cents(100_00), TaxClass: "standard", ItemID: 1}},
{Projection: catalog.Projection{ID: "sku:B", SKU: "B", PriceIncVat: money.Cents(50_00), TaxClass: "reduced", ItemID: 2}},
})); err != nil {
t.Fatal(err)
}
// end carries the total count for the completeness guard.
endMeta := frameMeta(catalog.SnapshotEnd, epoch)
endMeta[catalog.MetaCount] = "2"
if err := c.HandleFrame(endMeta, enc(t, nil)); err != nil {
t.Fatal(err)
}
if got, ok := c.Get("A"); !ok || got.PriceIncVat != 100_00 {
t.Fatalf("A from snapshot = %+v ok=%v, want 10000", got, ok)
}
// Epoch-matched delta: bump A's price, delete B.
if err := c.HandleFrame(frameMeta("", epoch), enc(t, []catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(90_00), TaxClass: "standard", ItemID: 1}},
{Projection: catalog.Projection{SKU: "B"}, Deleted: true},
})); err != nil {
t.Fatal(err)
}
if got, _ := c.Get("A"); got.PriceIncVat != 90_00 {
t.Fatalf("A after delta = %d, want 9000", got.PriceIncVat)
}
if _, ok := c.Get("B"); ok {
t.Fatalf("B after delete should miss")
}
if !c.IsDeleted("B") {
t.Fatalf("B should be tombstoned (skip HTTP fetch)")
}
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"testing"
"git.k6n.net/mats/platform/catalog"
)
// mustCache opens a per-pod projection cache backed by a temp dir (pod-local).
func mustCache(t *testing.T) *catalogProjectionCache {
t.Helper()
c, err := newCatalogProjectionCache(t.TempDir())
if err != nil {
t.Fatalf("newCatalogProjectionCache: %v", err)
}
t.Cleanup(func() { _ = c.store.Close() })
return c
}
// Apply is a TEST-ONLY convenience that routes a delta batch through the store at
// the initial epoch (0), reproducing the pre-ProjectionStore cache API so the
// existing cart tests keep exercising Get/IsDeleted/Len. Production code feeds the
// store via HandleFrame with real bus framing (snapshot + epoch-stamped deltas).
func (c *catalogProjectionCache) Apply(updates []catalog.ProjectionUpdate) (upserts, deletes int) {
for _, u := range updates {
if u.Deleted {
deletes++
} else if u.SKU != "" {
upserts++
}
}
payload, err := catalog.EncodeUpdates(updates)
if err != nil {
return 0, 0
}
// No MetaSnapshotPhase ⇒ delta; epoch 0 matches a fresh store's base epoch.
_ = c.store.HandleFrame(map[string]string{catalog.MetaEpoch: "0"}, payload)
return upserts, deletes
}
+180
View File
@@ -4,8 +4,11 @@ import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
)
@@ -13,6 +16,7 @@ import (
// (possibly partial) evaluation context and returns the totals plus the
// applied/pending promotion effects, without creating or mutating a real cart.
// An empty body is treated as an empty context (evaluates against no items).
// Pure stateless — callers compose the line list themselves.
func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.PromotionService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req promotions.EvaluateRequest
@@ -27,3 +31,179 @@ func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.Promot
}
}
}
// newPromotionEvaluateWithCartHandler serves POST /promotions/evaluate-with-cart.
// Reads the `cartid` cookie the storefront already maintains (set by
// GET /cart on first visit), fetches the actual cart from the grain pool
// (cross-pod via GetAnywhere so a cart owned by another pod is fetched
// transparently), deep-copies the grain, merges the PDP request items
// into the copy (replace-by-sku semantic so the PDP qty wins for any
// product already in the cart), and runs the canonical
// promotions.PromotionService.EvaluateAndApply pipeline the live cart
// processor uses — so the preview sees the cart's vouchers, customer
// segment, customer-lifetime-value, and computed total, not just the
// line list, and the coupon+promotion-overlap edge case is handled
// the same way on both sides. Returns the same EvaluateResponse shape
// as the stateless endpoint.
//
// Why this exists: the storefront's "I kundkorgen" preview needs the
// engine's verdict for "what would the cart look like if I add this
// product". The cart is the source of truth (it has vouchers, customer
// segment, customer-lifetime-value, total, etc. that the engine needs),
// and a serialised copy sent from the browser can race the live cart.
// Letting the backend read the cookie + fetch the grain means there is
// exactly one source of truth and no race. The grain is deep-copied via
// JSON marshal/unmarshal so the preview never mutates the live state —
// every read is purely a what-if computation.
//
// Missing/invalid cookie (new visitor, preview-only tab): the handler
// treats the request as a pure stateless evaluation of the request
// items alone — the "what would I get just for the product I'm
// viewing" answer is still useful on its own.
//
// Cart fetch failure (the grain is gone, the pool is degraded, etc.):
// the handler logs and falls through to the same stateless evaluation
// of the request items. The preview degrades gracefully rather than
// erroring the whole block.
//
// "Replace" merge semantic: if the same sku is in both the cart and
// the request items, the request item wins (with its qty). This
// matches the PDP's intent ("I am previewing adding this product
// at this qty") and avoids double-counting the same product.
func newPromotionEvaluateWithCartHandler(store *promotions.Store, svc *promotions.PromotionService, server *PoolServer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req promotions.EvaluateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
return
}
var resp promotions.EvaluateResponse
if g := cloneCartForPreview(server, r); g != nil {
mergeRequestItemsIntoGrain(g, req.Items, svc.DefaultTaxProvider)
// One call to the canonical pipeline — same code path
// the live cart processor's reg.RegisterProcessor in
// main.go uses. Going through the shared
// PromotionService.EvaluateAndApply means the bypass
// loop, the re-eval trigger, and ApplyResults stay
// in lockstep with post-add behavior. EvaluateAndApply
// populates g.EvaluatedItems via cart.MapEvaluatedItems
// on its final step, so the preview response reuses the
// same per-line projection the live cart renders — math
// and rounding stay in lockstep across both paths.
svc.EvaluateAndApply(store.Snapshot(), g, previewContextOptions(req)...)
resp = promotions.EvaluateResponse{
TotalPrice: g.TotalPrice,
TotalDiscount: g.TotalDiscount,
AppliedPromotions: g.AppliedPromotions,
Items: g.EvaluatedItems,
}
} else {
// No live cart (missing/invalid cookie, fetch failed, or
// deep-copy failed). Fall back to the pure stateless
// evaluation of the request items alone — the user still
// gets a useful preview of "what would the engine do for
// just the items I'm about to add".
resp = svc.Evaluate(store.Snapshot(), req)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
// cloneCartForPreview reads the cartid cookie, fetches the live grain
// (cross-pod via GetAnywhere), and returns a deep copy safe to mutate
// for the what-if preview. Returns nil (and logs) when the cookie is
// missing/invalid OR the grain fetch fails OR the deep-copy fails —
// the caller falls back to a stateless evaluation of the request items
// alone.
//
// The deep copy is via JSON marshal/unmarshal, which is intentional: it
// produces a fully independent *cart.CartGrain (re-allocating every
// pointer — Items, Vouchers, ItemMeta, Price, etc.) so the live grain
// is never touched, even if the live grain holds non-serializable
// unexported state like an in-memory event-log channel. Unexported
// fields are silently dropped by the json package, which is exactly
// what we want here — the engine only reads the grain's exported
// state (Items, Vouchers, TotalPrice, etc.), and the live grain's
// in-memory event log / channels must not be cloned or shared.
func cloneCartForPreview(server *PoolServer, r *http.Request) *cart.CartGrain {
cookie, err := r.Cookie("cartid")
if err != nil || cookie.Value == "" {
return nil
}
id, ok := cart.ParseCartId(cookie.Value)
if !ok {
log.Printf("promotions/evaluate-with-cart: invalid cartid cookie, falling back to stateless")
return nil
}
live, err := server.GetAnywhere(r.Context(), id)
if err != nil {
log.Printf("promotions/evaluate-with-cart: cart fetch failed for %s, falling back to stateless: %v", id, err)
return nil
}
if live == nil {
return nil
}
buf, err := json.Marshal(live)
if err != nil {
log.Printf("promotions/evaluate-with-cart: cart marshal failed for %s, falling back to stateless: %v", id, err)
return nil
}
var clone *cart.CartGrain
if err := json.Unmarshal(buf, &clone); err != nil {
log.Printf("promotions/evaluate-with-cart: cart unmarshal failed for %s, falling back to stateless: %v", id, err)
return nil
}
return clone
}
// mergeRequestItemsIntoGrain merges reqItems into the grain's items with
// "replace" semantic: any cart line whose sku also appears in the
// request is dropped (the request item wins, so the PDP qty replaces
// the cart qty for that product). Remaining cart lines are kept
// verbatim, then the converted request items are appended. The grain
// is mutated in place — caller is expected to be holding a deep copy
// from cloneCartForPreview. The EvalItem→CartItem conversion uses the
// shared promotions.EvalItem.ToCartItem helper so the math (VAT rate
// rounding, qty default, ItemMeta, Price) matches the synthetic-cart
// conversion in the stateless endpoint exactly.
func mergeRequestItemsIntoGrain(g *cart.CartGrain, reqItems []promotions.EvalItem, tp cart.TaxProvider) {
requestSkus := make(map[string]struct{}, len(reqItems))
for _, it := range reqItems {
if it.Sku != "" {
requestSkus[it.Sku] = struct{}{}
}
}
merged := make([]*cart.CartItem, 0, len(g.Items)+len(reqItems))
for _, it := range g.Items {
if _, taken := requestSkus[it.Sku]; taken {
continue
}
merged = append(merged, it)
}
for _, it := range reqItems {
merged = append(merged, it.ToCartItem(tp))
}
g.Items = merged
}
// previewContextOptions builds the ContextOption list for the
// cart-aware preview. The shared EvaluateRequest.ContextOptions
// already includes WithNow when req.Now is set, so we only prepend a
// default WithNow(time.Now()) when the request didn't specify one —
// avoiding a duplicate WithNow in the opts list (the engine's
// NewContextFromCart applies options in order, but the last-one-wins
// rule for duplicates is not a contract we want to rely on). The
// other context options (CustomerSegment, CLV, OrderCount) come from
// req.ContextOptions unchanged.
func previewContextOptions(req promotions.EvaluateRequest) []promotions.ContextOption {
opts := req.ContextOptions()
if req.Now == nil {
opts = append([]promotions.ContextOption{promotions.WithNow(time.Now())}, opts...)
}
return opts
}
+51
View File
@@ -151,6 +151,33 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
})
}
// Reflected applied promotions as negative discount lines
if grain.CartState != nil {
for _, ap := range grain.CartState.AppliedPromotions {
if ap.Pending {
continue
}
discountVal := int64(0)
if ap.Discount != nil {
discountVal = ap.Discount.IncVat.Int64()
}
if discountVal <= 0 {
continue
}
lines = append(lines, &Line{
Type: "discount",
Reference: "promo-" + ap.PromotionId,
Name: "Promotion: " + ap.Name,
Quantity: 1,
UnitPrice: int(-discountVal),
QuantityUnit: "st",
TotalAmount: int(-discountVal),
TaxRate: 2500,
TotalTaxAmount: int(-discountVal * 2500 / 12500),
})
}
}
order := &CheckoutOrder{
PurchaseCountry: country,
PurchaseCurrency: currency,
@@ -285,6 +312,30 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta
})
}
// Reflected applied promotions as negative discount lines
if grain.CartState != nil {
for _, ap := range grain.CartState.AppliedPromotions {
if ap.Pending {
continue
}
discountVal := int64(0)
if ap.Discount != nil {
discountVal = ap.Discount.IncVat.Int64()
}
if discountVal <= 0 {
continue
}
lineItems = append(lineItems, adyenCheckout.LineItem{
Quantity: common.PtrInt64(1),
AmountIncludingTax: common.PtrInt64(-discountVal),
Description: common.PtrString("Promotion: " + ap.Name),
AmountExcludingTax: common.PtrInt64(-discountVal * 10000 / 12500),
TaxAmount: common.PtrInt64(-discountVal * 2500 / 12500),
TaxPercentage: common.PtrInt64(2500),
})
}
}
return &adyenCheckout.CreateCheckoutSessionRequest{
Reference: grain.Id.String(),
Amount: adyenCheckout.Amount{
+1 -57
View File
@@ -1,65 +1,9 @@
package main
import (
"log"
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
func GetDiscovery() discovery.Discovery {
if podIp == "" {
return nil
}
config, kerr := rest.InClusterConfig()
if kerr != nil {
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
}
client, err := kubernetes.NewForConfig(config)
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
timeout := int64(30)
// Scope discovery to this pod's namespace so it only needs a namespaced Role
// (pods: get/list/watch), not a cluster-wide ClusterRole.
return discovery.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
LabelSelector: "actor-pool=checkout",
TimeoutSeconds: &timeout,
})
}
func UseDiscovery(pool discovery.DiscoveryTarget) {
go func(hw discovery.Discovery) {
if hw == nil {
log.Print("No discovery service available")
return
}
ch, err := hw.Watch()
if err != nil {
log.Printf("Discovery error: %v", err)
return
}
for evt := range ch {
if evt.Host == "" {
continue
}
switch evt.IsReady {
case false:
if pool.IsKnown(evt.Host) {
log.Printf("Host %s is not ready, removing", evt.Host)
pool.RemoveHost(evt.Host)
}
default:
if !pool.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
pool.AddRemoteHost(evt.Host)
}
}
}
}(GetDiscovery())
discovery.StartK8sDiscovery(podIp, "actor-pool=checkout", 30, pool)
}
+2 -19
View File
@@ -11,7 +11,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/platform/inventory"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -244,7 +244,7 @@ func createOrderFromCheckout(ctx context.Context, s *CheckoutPoolServer, grain *
func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" {
return "se"
return inventory.LocationID("se")
}
return inventory.LocationID(*item.StoreId)
}
@@ -259,23 +259,6 @@ func shouldTrackInventory(item *cart.CartItem) bool {
return false
}
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
var requests []inventory.ReserveRequest
for _, item := range items {
if item == nil || !shouldTrackInventory(item) {
continue
}
requests = append(requests, inventory.ReserveRequest{
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(item.Sku),
LocationID: getLocationId(item),
},
Quantity: uint32(item.Quantity),
})
}
return requests
}
func (a *CheckoutPoolServer) getGrainFromKlarnaOrder(ctx context.Context, order *CheckoutOrder) (*checkout.CheckoutGrain, error) {
cartId, ok := cart.ParseCartId(order.MerchantReference1)
if !ok {
+57 -15
View File
@@ -15,23 +15,22 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
redisinv "git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/platform/config"
"git.k6n.net/mats/platform/rabbit"
"git.k6n.net/mats/platform/tax"
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
"github.com/adyen/adyen-go-api-library/v21/src/common"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
var (
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
Name: "checkout_grain_spawned_total",
Help: "The total number of spawned checkout grains",
})
// checkoutMetrics is the shared prometheus instrumentation for the
// checkout actor pool and event log. Built once at process start;
// the actor package's touch sites are nil-safe so a test binary
// could pass nil.
checkoutMetrics = actor.NewMetrics("checkout")
)
func init() {
@@ -102,29 +101,27 @@ func main() {
Password: redisPassword,
DB: 0,
})
inventoryService, err := inventory.NewRedisInventoryService(rdb)
if err != nil {
log.Fatalf("Error creating inventory service: %v\n", err)
}
reservationService, err := inventory.NewRedisCartReservationService(rdb)
reservationService, err := redisinv.NewRedisCartReservationService(rdb)
if err != nil {
log.Fatalf("Error creating reservation service: %v\n", err)
}
reservationPolicy := redisinv.NewReservationPolicyAdapter(reservationService)
checkoutDir := os.Getenv("CHECKOUT_DIR")
if checkoutDir == "" {
checkoutDir = "data"
}
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg)
diskStorage.SetMetrics(checkoutMetrics)
var syncedServer *CheckoutPoolServer
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
MutationRegistry: reg,
Storage: diskStorage,
Metrics: checkoutMetrics,
Spawn: func(ctx context.Context, id uint64) (actor.Grain[checkout.CheckoutGrain], error) {
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn checkout id %d", id))
defer span.End()
grainSpawns.Inc()
ret := checkout.NewCheckoutGrain(id, 0, 0, time.Now(), nil) // version to be set later
// Load persisted events/state for this checkout if present
@@ -194,8 +191,7 @@ func main() {
pool.AddListener(checkoutFeed)
syncedServer = NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler)
syncedServer.inventoryService = inventoryService
syncedServer.reservationService = reservationService
syncedServer.reservationPolicy = reservationPolicy
syncedServer.taxProvider = selectTaxProvider()
mux := http.NewServeMux()
@@ -216,6 +212,31 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
// Data Retention (C5) GDPR Purge
retentionDays := config.EnvInt("CHECKOUT_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 })
if retentionDays > 0 {
purgeInterval := config.EnvDuration("CHECKOUT_PURGE_INTERVAL", 24*time.Hour)
log.Printf("checkout: data retention enabled. Retaining checkouts for %d days, purging every %v", retentionDays, purgeInterval)
go func() {
ticker := time.NewTicker(purgeInterval)
defer ticker.Stop()
// Run immediately on startup
runPurge(ctx, diskStorage, pool, retentionDays)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runPurge(ctx, diskStorage, pool, retentionDays)
}
}
}()
} else {
log.Print("checkout: data retention / GDPR purge disabled (CHECKOUT_RETENTION_DAYS not set or <= 0)")
}
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
if err != nil {
log.Fatalf("Unable to start otel %v", err)
@@ -303,3 +324,24 @@ func main() {
stop()
}
}
func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[checkout.CheckoutGrain], pool *actor.SimpleGrainPool[checkout.CheckoutGrain], retentionDays int) {
log.Printf("checkout: starting data retention purge...")
retention := time.Duration(retentionDays) * 24 * time.Hour
localIds := make(map[uint64]bool)
for _, id := range pool.GetLocalIds() {
localIds[id] = true
}
isGrainActive := func(id uint64) bool {
return localIds[id]
}
purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive)
if err != nil {
log.Printf("checkout: data retention purge failed: %v", err)
} else {
log.Printf("checkout: data retention purge completed. Purged %d expired checkout logs", purged)
}
}
+1
View File
@@ -66,6 +66,7 @@ type OrderLine struct {
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unitPrice"`
TaxRate int32 `json:"taxRate"`
DropShip bool `json:"drop_ship,omitempty"`
Location string `json:"location,omitempty"`
}
+12
View File
@@ -6,7 +6,9 @@ import (
"fmt"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
profile "git.k6n.net/mats/go-cart-actor/pkg/profile"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
profileMessages "git.k6n.net/mats/go-cart-actor/proto/profile"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -58,6 +60,7 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
// CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale
// (2500 = 25%), so the rate passes through unchanged.
TaxRate: int32(it.Tax),
DropShip: it.DropShip,
Location: location,
})
}
@@ -159,6 +162,15 @@ func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer,
Status: "completed",
CreatedAt: timestamppb.Now(),
})
if grain.CartState != nil && grain.CartState.UserId != "" {
if pid, ok := profile.ParseProfileId(grain.CartState.UserId); ok {
_ = s.ApplyAnywhere(ctx, checkout.CheckoutId(pid), &profileMessages.LinkOrder{
OrderReference: result.OrderId,
CartId: uint64(grain.CartId),
Status: "completed",
})
}
}
return result, nil
}
createErr = err
+11 -17
View File
@@ -16,11 +16,9 @@ import (
"git.k6n.net/mats/platform/tax"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
"git.k6n.net/mats/platform/inventory"
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"google.golang.org/protobuf/proto"
@@ -33,16 +31,13 @@ import (
"go.opentelemetry.io/otel/trace"
)
var (
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
Name: "checkout_grain_mutations_total",
Help: "The total number of mutations",
})
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
Name: "checkout_grain_lookups_total",
Help: "The total number of lookups",
})
)
// Pool metrics (checkout_grain_spawned_total, checkout_grain_lookups_total,
// checkout_mutations_total, checkout_remote_negotiation_total,
// checkout_connected_remotes, …) are bumped centrally by
// SimpleGrainPool — see pkg/actor/simple_grain_pool.go. The checkout
// Metrics is registered once in cmd/checkout/main.go and shared with
// DiskStorage via SetMetrics, so the per-handler counters this file
// used to maintain are no longer needed.
type CheckoutPoolServer struct {
actor.GrainPool[checkout.CheckoutGrain]
@@ -52,8 +47,7 @@ type CheckoutPoolServer struct {
cartClient *CartClient
orderClient *OrderClient
orderHandler *AmqpOrderHandler
inventoryService *inventory.RedisInventoryService
reservationService *inventory.RedisCartReservationService
reservationPolicy inventory.ReservationPolicy
taxProvider tax.Provider
}
@@ -556,14 +550,14 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
}
func (s *CheckoutPoolServer) releaseCartReservations(ctx context.Context, grain *checkout.CheckoutGrain) {
if s.reservationService == nil || grain.CartState == nil {
if s.reservationPolicy == nil || grain.CartState == nil {
return
}
for _, item := range grain.CartState.Items {
if item == nil || !shouldTrackInventory(item) {
continue
}
if err := s.reservationService.ReleaseForCart(ctx, inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
if err := s.reservationPolicy.Release(ctx, inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
logger.WarnContext(ctx, "failed to release cart reservation", "sku", item.Sku, "err", err)
}
}
+18 -6
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"fmt"
"log"
"net/http"
"strings"
@@ -9,6 +10,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/platform/inventory"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
@@ -77,12 +79,23 @@ func getCountryFromHost(host string) string {
}
func (a *CheckoutPoolServer) reserveInventory(ctx context.Context, grain *checkout.CheckoutGrain) error {
if a.inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.CartState.Items)
_, err := a.inventoryService.ReservationCheck(ctx, inventoryRequests...)
if a.reservationPolicy == nil {
return nil
}
for _, item := range grain.CartState.Items {
if item == nil || !shouldTrackInventory(item) {
continue
}
loc := inventory.LocationID("se")
if item.StoreId != nil && *item.StoreId != "" {
loc = inventory.LocationID(*item.StoreId)
}
avail, err := a.reservationPolicy.Available(ctx, inventory.SKU(item.Sku), loc)
if err != nil {
logger.WarnContext(ctx, "placeorder inventory check failed")
return err
return fmt.Errorf("inventory check failed for SKU %s: %w", item.Sku, err)
}
if int64(item.Quantity) > avail {
return fmt.Errorf("insufficient inventory for SKU %s: have %d, need %d", item.Sku, avail, item.Quantity)
}
}
return nil
@@ -154,7 +167,6 @@ func (s *CheckoutPoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
handled, err := ownerHost.Proxy(uint64(checkoutId), w, r, nil)
grainLookups.Inc()
if err == nil && handled {
return nil
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"encoding/json"
"io"
"net/http"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
)
// batchItem is one stock set in a batch request. LocationID is optional; an
// empty value defaults to the service's country location (the same location the
// catalog-feed listener writes to).
type batchItem struct {
SKU string `json:"sku"`
LocationID string `json:"locationId,omitempty"`
Quantity int64 `json:"quantity"`
}
// batchResult mirrors one input item with the location actually written and an
// optional per-item error (empty on success).
type batchResult struct {
SKU string `json:"sku"`
LocationID string `json:"locationId"`
Quantity int64 `json:"quantity"`
Error string `json:"error,omitempty"`
}
// batchInventoryHandler SETS stock to absolute values for many SKUs in one
// pipelined call — for re-seeding/import and for testing the inventory + reserve
// paths directly. Same write as the catalog-feed listener (UpdateInventory +
// level crossing), so a value set here is authoritative until the next
// catalog.item_changed for that SKU overwrites it (catalog is the source of
// truth). Body is a JSON array of {sku, locationId?, quantity}.
func (srv *Server) batchInventoryHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 16<<20)) // 16 MiB
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var items []batchItem
if err := json.Unmarshal(body, &items); err != nil {
http.Error(w, "body must be a JSON array of {sku, locationId?, quantity}: "+err.Error(), http.StatusBadRequest)
return
}
if len(items) == 0 {
http.Error(w, "empty batch", http.StatusBadRequest)
return
}
ctx := r.Context()
results := make([]batchResult, len(items))
pipe := srv.rdb.Pipeline()
for i, it := range items {
loc := it.LocationID
if loc == "" {
loc = country
}
results[i] = batchResult{SKU: it.SKU, LocationID: loc, Quantity: it.Quantity}
if it.SKU == "" {
results[i].Error = "sku is required"
continue
}
srv.inventoryService.UpdateInventory(ctx, pipe, inventory.SKU(it.SKU), inventory.LocationID(loc), it.Quantity)
}
hadError := false
if _, err := pipe.Exec(ctx); err != nil {
// Exec reports the first failing command; flag the whole batch rather than
// guess which items committed.
hadError = true
for i := range results {
if results[i].Error == "" {
results[i].Error = err.Error()
}
}
} else {
// Emit level crossings for the items that were actually written, so the
// finder/level listeners react the same way they do for catalog feeds.
for i := range results {
if results[i].Error == "" {
emitLevelIfChanged(ctx, srv.rdb, srv.conn, country, lowWatermark, results[i].SKU, results[i].LocationID, results[i].Quantity)
}
}
}
for i := range results {
if results[i].Error != "" {
hadError = true
break
}
}
status := http.StatusOK
if hadError {
status = http.StatusMultiStatus // 207
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results})
}
+5
View File
@@ -40,6 +40,11 @@ func commitOrder(ctx context.Context, rdb *redis.Client, svc *inventory.RedisInv
if line.SKU == "" || line.Quantity <= 0 {
continue
}
// Drop-ship items are fulfilled by the supplier — never decrement
// local stock for them.
if line.DropShip {
continue
}
loc := inventory.LocationID(defaultLoc)
if line.Location != "" {
loc = inventory.LocationID(line.Location)
+12 -1
View File
@@ -24,6 +24,11 @@ import (
type Server struct {
inventoryService *inventory.RedisInventoryService
reservationService *inventory.RedisCartReservationService
// rdb + conn back the batch write path (pipelined UpdateInventory + level
// crossing). conn is nil when RABBIT_HOST is unset; emit is then skipped.
rdb *redis.Client
conn *rabbit.Conn
}
func (srv *Server) livezHandler(w http.ResponseWriter, r *http.Request) {
@@ -124,13 +129,16 @@ func main() {
return
}
server := &Server{inventoryService: s, reservationService: r}
server := &Server{inventoryService: s, reservationService: r, rdb: rdb}
// Set up HTTP routes
http.HandleFunc("/livez", server.livezHandler)
http.HandleFunc("/readyz", server.readyzHandler)
http.HandleFunc("/inventory/{sku}/{locationId}", server.getInventoryHandler)
http.HandleFunc("/reservations/{sku}/{locationId}", server.getReservationHandler)
http.HandleFunc("POST /reservations", server.reserveInventoryHandler)
http.HandleFunc("POST /reservations/release", server.releaseReservationHandler)
http.HandleFunc("POST /inventory/batch", server.batchInventoryHandler)
stockhandler := &StockHandler{
MainStockLocationID: inventory.LocationID(country),
@@ -151,6 +159,9 @@ func main() {
// The catalog-feed handler emits inventory.level_changed crossings
// directly on this connection as it writes stock.
stockhandler.conn = conn
// Share the bus connection with the batch handler so its writes emit
// inventory.level_changed crossings too.
server.conn = conn
// Reconnecting consumer: re-listen on reconnect.
conn.NotifyOnReconnect(func() {
ch, err := conn.Channel()
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"encoding/json"
"errors"
"net/http"
"time"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
)
type reservationLineReq struct {
SKU string `json:"sku"`
LocationID string `json:"locationId"`
Quantity uint32 `json:"quantity"`
}
type reservationBatchReq struct {
HolderID string `json:"holderId"`
TTLSeconds int64 `json:"ttlSeconds,omitempty"`
Lines []reservationLineReq `json:"lines"`
}
func (srv *Server) reserveInventoryHandler(w http.ResponseWriter, r *http.Request) {
var req reservationBatchReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.HolderID == "" {
http.Error(w, "missing holderId", http.StatusBadRequest)
return
}
if len(req.Lines) == 0 {
http.Error(w, "missing lines", http.StatusBadRequest)
return
}
ttl := 15 * time.Minute
if req.TTLSeconds > 0 {
ttl = time.Duration(req.TTLSeconds) * time.Second
}
for _, line := range req.Lines {
if line.SKU == "" || line.LocationID == "" || line.Quantity == 0 {
http.Error(w, "each line must include sku, locationId, and quantity", http.StatusBadRequest)
return
}
if err := srv.reservationService.ReserveForCart(r.Context(), inventory.CartReserveRequest{
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(line.SKU),
LocationID: inventory.LocationID(line.LocationID),
},
CartID: inventory.CartID(req.HolderID),
Quantity: line.Quantity,
TTL: ttl,
}); err != nil {
status := http.StatusInternalServerError
if errors.Is(err, inventory.ErrInsufficientInventory) {
status = http.StatusConflict
}
http.Error(w, err.Error(), status)
return
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": "reserved", "holderId": req.HolderID})
}
func (srv *Server) releaseReservationHandler(w http.ResponseWriter, r *http.Request) {
var req reservationBatchReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.HolderID == "" {
http.Error(w, "missing holderId", http.StatusBadRequest)
return
}
if len(req.Lines) == 0 {
http.Error(w, "missing lines", http.StatusBadRequest)
return
}
for _, line := range req.Lines {
if line.SKU == "" || line.LocationID == "" {
http.Error(w, "each line must include sku and locationId", http.StatusBadRequest)
return
}
if err := srv.reservationService.ReleaseForCart(r.Context(),
inventory.SKU(line.SKU),
inventory.LocationID(line.LocationID),
inventory.CartID(req.HolderID),
); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": "released", "holderId": req.HolderID})
}
+2 -5
View File
@@ -10,8 +10,8 @@ import (
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
"git.k6n.net/mats/go-cart-actor/pkg/order"
)
@@ -41,10 +41,7 @@ func testServer(t *testing.T) *server {
applier := &orderedApplier{pool: pool, storage: storage}
provider := order.NewPassthroughProvider("legacy", "ref", 15000)
freg := flow.NewRegistry()
flow.RegisterBuiltinHooks(freg)
order.RegisterFlowActions(freg, applier, provider)
order.RegisterEmitHook(freg, nil)
freg := buildFlowRegistry(applier, provider, nil, nil, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
engine := flow.NewEngine(freg, slog.New(slog.NewTextHandler(io.Discard, nil)))
def, err := order.EmbeddedFlow("place-and-pay")
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/order"
)
type projectFulfillmentHookParams struct {
Integration string `json:"integration,omitempty"`
Target string `json:"target,omitempty"`
Dropship bool `json:"dropship,omitempty"`
}
// registerProjectFlowHooks is the project-layer seam for customer-specific flow
// integrations. These belong here rather than pkg/order so core order logic
// stays generic while deployments can register their own fulfillment tools.
func registerProjectFlowHooks(reg *flow.Registry, app *orderedApplier, logger *slog.Logger) {
if logger == nil {
logger = slog.Default()
}
reg.HookWithMeta("project_fulfillment_integration", func(ctx context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
var p projectFulfillmentHookParams
if len(params) > 0 {
if err := json.Unmarshal(params, &p); err != nil {
return fmt.Errorf("project_fulfillment_integration: bad params: %w", err)
}
}
if p.Integration == "" {
p.Integration = "order-integration"
}
o, err := app.Get(ctx, st.ID)
if err != nil {
return err
}
if len(o.Fulfillments) == 0 {
return fmt.Errorf("project_fulfillment_integration: order %s has no fulfillment yet", order.OrderId(st.ID).String())
}
last := o.Fulfillments[len(o.Fulfillments)-1]
log := st.Logger
if log == nil {
log = logger
}
log.Info("project fulfillment integration",
"orderId", order.OrderId(st.ID).String(),
"orderReference", o.OrderReference,
"customerEmail", o.CustomerEmail,
"integration", p.Integration,
"target", p.Target,
"dropship", p.Dropship,
"step", info.Step,
"phase", info.Phase,
"fulfillmentId", last.ID,
"carrier", last.Carrier,
"trackingNumber", last.TrackingNumber,
)
return nil
}, flow.CapabilityMeta{
Description: "Project-specific fulfillment integration seam for local or testing adapters.",
ExampleParams: json.RawMessage(`{"integration":"dropship-test","target":"erp-sandbox","dropship":true}`),
})
}
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"strings"
"testing"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/order"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
)
func TestProjectFulfillmentIntegrationHookIsRegistered(t *testing.T) {
s := testServer(t)
if !strings.Contains(strings.Join(s.freg.Capabilities().Hooks, ","), "project_fulfillment_integration") {
t.Fatalf("hooks = %v, want project_fulfillment_integration", s.freg.Capabilities().Hooks)
}
}
func TestProjectFulfillmentIntegrationLogs(t *testing.T) {
s := testServer(t)
var logs bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&logs, nil))
provider := order.NewPassthroughProvider("legacy", "ref", 25000)
freg := buildFlowRegistry(s.applier, provider, nil, nil, nil, nil, nil, logger)
engine := flow.NewEngine(freg, logger)
st := flow.NewState(801, logger)
po := orderTestPlaceMsg()
po.CustomerEmail = "dropship@example.com"
st.Vars[order.PlaceOrderVar] = po
def, err := order.EmbeddedFlow("place-and-pay")
if err != nil {
t.Fatal(err)
}
if _, err := engine.Run(context.Background(), def, st); err != nil {
t.Fatal(err)
}
ship := &flow.Definition{Name: "ship", Steps: []flow.Step{{
Name: "fulfill",
Action: "create_fulfillment",
Params: json.RawMessage(`{"carrier":"dropshipper","trackingNumber":"DS-123","lines":[{"reference":"l1","quantity":1}]}`),
Hooks: flow.Hooks{After: []flow.HookRef{{
Type: "project_fulfillment_integration",
Params: json.RawMessage(`{"integration":"dropship-test","target":"erp-sandbox","dropship":true}`),
}}},
}}}
if _, err := engine.Run(context.Background(), ship, flow.NewState(801, logger)); err != nil {
t.Fatal(err)
}
got := logs.String()
for _, want := range []string{
`"msg":"project fulfillment integration"`,
`"integration":"dropship-test"`,
`"target":"erp-sandbox"`,
`"dropship":true`,
`"trackingNumber":"DS-123"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("log output missing %s: %s", want, got)
}
}
}
func orderTestPlaceMsg() *messages.PlaceOrder {
return &messages.PlaceOrder{
OrderReference: "ref-1",
CartId: "cart-1",
Currency: "SEK",
Country: "SE",
CustomerName: "Dropship Customer",
TotalAmount: 25000,
TotalTax: 5000,
PlacedAtMs: 1718877600000,
Lines: []*messages.OrderLine{
{Reference: "l1", Sku: "ABC", Name: "Widget", Quantity: 2, UnitPrice: 10000, TaxRate: 25, TotalAmount: 20000},
{Reference: "l2", Sku: "DEF", Name: "Gizmo", Quantity: 1, UnitPrice: 5000, TaxRate: 25, TotalAmount: 5000},
},
}
}
+221
View File
@@ -0,0 +1,221 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"git.k6n.net/mats/go-cart-actor/pkg/order"
)
// emailTemplateInfo is the metadata returned by the list endpoint.
type emailTemplateInfo struct {
Name string `json:"name"`
Embedded bool `json:"embedded,omitempty"`
}
// emailTemplateHandler serves CRUD operations for email templates stored in the
// ORDER_EMAIL_TEMPLATES directory. Embedded templates are read-only; on-disk
// copies override them and can be edited via PUT.
type emailTemplateHandler struct {
dir string // the on-disk templates directory, from ORDER_EMAIL_TEMPLATES env
}
func newEmailTemplateHandler(dir string) *emailTemplateHandler {
if dir == "" {
dir = "data/order-email-templates"
}
return &emailTemplateHandler{dir: dir}
}
// list returns all known email templates (embedded + on-disk) with their source.
func (h *emailTemplateHandler) list(w http.ResponseWriter, r *http.Request) {
embedded := knownEmbeddedTemplates()
onDisk := h.listOnDisk()
seen := map[string]bool{}
var infos []emailTemplateInfo
// List on-disk first (they take precedence).
for _, name := range onDisk {
infos = append(infos, emailTemplateInfo{Name: name})
seen[name] = true
}
// Then add embedded templates not overridden by disk.
for _, name := range embedded {
if !seen[name] {
infos = append(infos, emailTemplateInfo{Name: name, Embedded: true})
seen[name] = true
}
}
if infos == nil {
infos = []emailTemplateInfo{}
}
writeJSON(w, http.StatusOK, infos)
}
// get returns the template content for the given name.
func (h *emailTemplateHandler) get(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
return
}
store, err := order.LoadEmailTemplates(h.dir)
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("load templates: %w", err))
return
}
tmpl, err := store.Get(name)
if err != nil {
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found", name))
return
}
writeJSON(w, http.StatusOK, tmpl)
}
// save persists a template to the on-disk directory. Returns the saved template.
func (h *emailTemplateHandler) save(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" || !validEmailTemplateName(name) {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid template name %q", name))
return
}
var tmpl order.EmailTemplate
if err := json.NewDecoder(r.Body).Decode(&tmpl); err != nil {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid template body: %w", err))
return
}
if err := tmpl.Validate(name); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := os.MkdirAll(h.dir, 0o755); err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("create templates dir: %w", err))
return
}
data, err := json.MarshalIndent(tmpl, "", " ")
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("marshal template: %w", err))
return
}
path := filepath.Join(h.dir, name+".json")
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("write template: %w", err))
return
}
writeJSON(w, http.StatusOK, tmpl)
}
// delete removes an on-disk template. Embedded templates cannot be deleted.
func (h *emailTemplateHandler) delete(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
return
}
// Check if it's embedded (read-only).
if isEmbeddedTemplate(name) {
writeErr(w, http.StatusForbidden, fmt.Errorf("template %q is built-in and cannot be deleted; save an override instead", name))
return
}
path := filepath.Join(h.dir, name+".json")
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found on disk", name))
return
}
writeErr(w, http.StatusInternalServerError, fmt.Errorf("delete template: %w", err))
return
}
w.WriteHeader(http.StatusNoContent)
}
// preview renders a template with sample data and returns the result.
func (h *emailTemplateHandler) preview(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
return
}
store, err := order.LoadEmailTemplates(h.dir)
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("load templates: %w", err))
return
}
tmpl, err := store.Get(name)
if err != nil {
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found", name))
return
}
// Build sample data for preview.
sampleData := order.SampleTemplateData()
subject, _ := order.RenderTextTemplate("preview-subject", tmpl.Subject, sampleData)
textBody, _ := order.RenderTextTemplate("preview-text", tmpl.Text, sampleData)
htmlBody, _ := order.RenderOptionalHTMLTemplate("preview-html", tmpl.HTML, sampleData)
writeJSON(w, http.StatusOK, map[string]string{
"subject": subject,
"text": textBody,
"html": htmlBody,
})
}
// listOnDisk returns template names from the on-disk directory.
func (h *emailTemplateHandler) listOnDisk() []string {
matches, err := filepath.Glob(filepath.Join(h.dir, "*.json"))
if err != nil {
return nil
}
var names []string
for _, m := range matches {
name := strings.TrimSuffix(filepath.Base(m), ".json")
if validEmailTemplateName(name) {
names = append(names, name)
}
}
sort.Strings(names)
return names
}
// knownEmbeddedTemplates returns the names of templates embedded in the binary.
// This must be kept in sync with the files in pkg/order/email_templates/.
func knownEmbeddedTemplates() []string {
return []string{
"fulfillment-shipped",
"order-confirmation",
"payment-receipt",
"shipment-delivered",
"return-confirmed",
"refund-issued",
}
}
// isEmbeddedTemplate reports whether name matches one of the embedded templates.
func isEmbeddedTemplate(name string) bool {
for _, e := range knownEmbeddedTemplates() {
if e == name {
return true
}
}
return false
}
// validEmailTemplateName validates a template name (alphanumeric + hyphens).
func validEmailTemplateName(s string) bool {
if s == "" || len(s) > 64 {
return false
}
for _, r := range s {
if !(r == '-' || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')) {
return false
}
}
return true
}
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"log/slog"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/order"
)
func buildFlowRegistry(
applier *orderedApplier,
provider order.PaymentProvider,
inventoryReservations order.InventoryReservationService,
emitPub order.Publisher,
emailSender order.EmailSender,
emailTemplates order.EmailTemplateStore,
prefChecker order.EmailPreferenceChecker,
logger *slog.Logger,
) *flow.Registry {
freg := flow.NewRegistry()
flow.RegisterBuiltinHooks(freg)
order.RegisterFlowActions(freg, applier, provider, prefChecker)
order.RegisterInventoryReservationActions(freg, applier, inventoryReservations)
order.RegisterEmitHook(freg, emitPub)
order.RegisterEmailHook(freg, applier, emailSender, emailTemplates, prefChecker)
order.RegisterFulfillmentWebhookHook(freg, applier, nil)
order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order")
registerProjectFlowHooks(freg, applier, logger)
return freg
}
+2 -5
View File
@@ -120,11 +120,7 @@ func (s *server) createOrderFromCheckoutInternal(ctx context.Context, req *fromC
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
}
freg := flow.NewRegistry()
flow.RegisterBuiltinHooks(freg)
order.RegisterFlowActions(freg, s.applier, provider)
order.RegisterEmitHook(freg, nil)
freg := buildFlowRegistry(s.applier, provider, s.inventoryReservations, nil, nil, nil, nil, s.logger)
engine := flow.NewEngine(freg, s.logger)
st := flow.NewState(ordID, s.logger)
st.Vars[order.PlaceOrderVar] = po
@@ -178,6 +174,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
TotalAmount: lineTotal,
TotalTax: lineTax,
Location: l.Location,
DropShip: l.DropShip,
})
}
po.TotalAmount = total
+9
View File
@@ -0,0 +1,9 @@
package main
import (
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
)
func UseDiscovery(pool discovery.DiscoveryTarget) {
discovery.StartK8sDiscovery(podIp, "actor-pool=order", 30, pool)
}
+278 -13
View File
@@ -12,10 +12,12 @@ import (
"log"
"log/slog"
"net/http"
"net/mail"
"os"
"path/filepath"
"strconv"
"strings"
"sort"
"sync"
"time"
@@ -26,6 +28,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/order"
ordermcp "git.k6n.net/mats/go-cart-actor/pkg/order/mcp"
"git.k6n.net/mats/go-cart-actor/pkg/outbox"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"git.k6n.net/mats/platform/config"
"git.k6n.net/mats/platform/rabbit"
@@ -35,6 +38,9 @@ import (
"google.golang.org/protobuf/proto"
)
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
type server struct {
pool *actor.SimpleGrainPool[order.OrderGrain]
applier *orderedApplier
@@ -49,13 +55,14 @@ type server struct {
dataDir string
idem *idempotency.Store
logger *slog.Logger
inventoryReservations order.InventoryReservationService
}
func main() {
addr := config.EnvString("ORDER_ADDR", ":8092") // :8090 cart, :8091 redirector
dataDir := config.EnvString("ORDER_DATA", "data/orders")
flowsDir := config.EnvString("ORDER_FLOWS", "data/order-flows")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
if err := os.MkdirAll(dataDir, 0755); err != nil {
log.Fatalf("create data dir: %v", err)
}
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
@@ -64,8 +71,13 @@ func main() {
order.RegisterMutations(reg)
storage := actor.NewDiskStorage[order.OrderGrain](dataDir, reg)
hostname := podIp
if hostname == "" {
hostname = "order-1"
}
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{
Hostname: "order-1",
Hostname: hostname,
Spawn: func(ctx context.Context, id uint64) (actor.Grain[order.OrderGrain], error) {
g := order.NewOrderGrain(id, time.Now())
// Replay any persisted history so the resident grain is current.
@@ -74,8 +86,8 @@ func main() {
}
return g, nil
},
SpawnHost: func(string) (actor.Host[order.OrderGrain], error) {
return nil, fmt.Errorf("order service is single-node")
SpawnHost: func(host string) (actor.Host[order.OrderGrain], error) {
return proxy.NewRemoteHost[order.OrderGrain](host)
},
Destroy: func(actor.Grain[order.OrderGrain]) error { return nil },
TTL: time.Hour,
@@ -93,6 +105,15 @@ func main() {
log.Fatalf("create pool: %v", err)
}
controlPlaneConfig := actor.DefaultServerConfig()
grpcSrv, err := actor.NewControlServer[order.OrderGrain](controlPlaneConfig, pool)
if err != nil {
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
}
defer grpcSrv.GracefulStop()
UseDiscovery(pool)
applier := &orderedApplier{pool: pool, storage: storage}
provider := selectProvider(logger)
@@ -101,6 +122,18 @@ func main() {
// the broker (publisher is nil without AMQP — the hook then errors at run
// time, which is logged, not fatal).
amqpURL := os.Getenv("AMQP_URL")
emailTemplates, err := order.LoadEmailTemplates(config.EnvString("ORDER_EMAIL_TEMPLATES", "data/order-email-templates"))
if err != nil {
log.Fatalf("load email templates: %v", err)
}
emailSender, err := selectEmailSender(logger)
if err != nil {
log.Fatalf("configure email sender: %v", err)
}
inventoryReservations, err := selectInventoryReservationService()
if err != nil {
log.Fatalf("configure inventory reservation service: %v", err)
}
var emitPub order.Publisher
if amqpURL != "" {
// The amqp_emit hook writes to a durable outbox rather than the broker
@@ -138,20 +171,29 @@ func main() {
}
}
}
freg := flow.NewRegistry()
flow.RegisterBuiltinHooks(freg)
order.RegisterFlowActions(freg, applier, provider)
order.RegisterEmitHook(freg, emitPub)
// order.created domain event → durable outbox → "order" topic exchange.
// Reactors: inventory commit, fulfillment allocation.
order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order")
// Email preference checker: when a profile service URL is configured, look up
// customer email preferences before sending transactional emails.
var prefChecker order.EmailPreferenceChecker
if profileURL := os.Getenv("PROFILE_URL"); profileURL != "" {
prefChecker = order.NewPreferencesChecker(profileURL + "/ucp/v1/customers")
logger.Info("email preference checking enabled", "profile_url", profileURL)
}
freg := buildFlowRegistry(applier, provider, inventoryReservations, emitPub, emailSender, emailTemplates, prefChecker, logger)
engine := flow.NewEngine(freg, logger)
def, err := order.EmbeddedFlow("place-and-pay")
if err != nil {
log.Fatalf("load flow: %v", err)
}
flows, err := newFlowStore(flowsDir, engine, map[string]*flow.Definition{def.Name: def})
fulfillDef, err := order.EmbeddedFlow("fulfill-order")
if err != nil {
log.Fatalf("load fulfill flow: %v", err)
}
flows, err := newFlowStore(flowsDir, engine, map[string]*flow.Definition{
def.Name: def,
fulfillDef.Name: fulfillDef,
})
if err != nil {
log.Fatalf("load flows: %v", err)
}
@@ -171,6 +213,7 @@ func main() {
engine: engine, freg: freg, flows: flows, provider: provider,
taxProvider: taxProvider,
defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger,
inventoryReservations: inventoryReservations,
}
mux := http.NewServeMux()
@@ -180,6 +223,7 @@ func main() {
mux.HandleFunc("POST /checkout", s.handleCheckout)
mux.HandleFunc("POST /api/orders/from-checkout", s.handleFromCheckout)
mux.HandleFunc("GET /api/orders", s.handleList)
mux.HandleFunc("GET /api/orders/stats", s.handleStats)
mux.HandleFunc("GET /api/orders/{id}", s.handleGet)
mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents)
@@ -218,6 +262,14 @@ func main() {
mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow)
mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow)
// Email template admin API (used by the backoffice Email Template Manager).
eth := newEmailTemplateHandler(config.EnvString("ORDER_EMAIL_TEMPLATES", "data/order-email-templates"))
mux.HandleFunc("GET /api/order-email-templates", eth.list)
mux.HandleFunc("GET /api/order-email-templates/{name}", eth.get)
mux.HandleFunc("PUT /api/order-email-templates/{name}", eth.save)
mux.HandleFunc("DELETE /api/order-email-templates/{name}", eth.delete)
mux.HandleFunc("POST /api/order-email-templates/{name}/preview", eth.preview)
// Ingest checkout orders from order-queue (Klarna/Adyen fallback).
if amqpURL != "" {
startOrderIngest(context.Background(), amqpURL, s)
@@ -278,6 +330,7 @@ type lineReq struct {
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units
TaxRate int32 `json:"taxRate"` // basis points (2500 = 25%)
DropShip bool `json:"drop_ship,omitempty"`
Location string `json:"location,omitempty"` // inventory commit location / store id
}
@@ -384,6 +437,7 @@ func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.P
TaxRate: l.TaxRate,
TotalAmount: lineTotal,
TotalTax: lineTax,
DropShip: l.DropShip,
})
}
po.TotalAmount = total
@@ -391,6 +445,160 @@ func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.P
return po
}
// --- dashboard stats ------------------------------------------------------
type orderAlert struct {
Severity string `json:"severity"` // warn | error
Message string `json:"message"`
OrderId string `json:"orderId,omitempty"`
}
type statusBucket struct {
Status order.Status `json:"status"`
Count int `json:"count"`
Total int64 `json:"total"`
}
type revenueSnapshot struct {
Total int64 `json:"total"`
Captured int64 `json:"captured"`
Refunded int64 `json:"refunded"`
Pending int64 `json:"pending"`
}
type dailyRevenue struct {
Date string `json:"date"` // YYYY-MM-DD
Total int64 `json:"total"`
}
type orderStatsResponse struct {
OrdersTotal int `json:"ordersTotal"`
Revenue revenueSnapshot `json:"revenue"`
ByStatus []statusBucket `json:"byStatus"`
RecentOrders []orderSummary `json:"recentOrders"`
DailyRevenue30 []dailyRevenue `json:"dailyRevenue30,omitempty"`
Alerts []orderAlert `json:"alerts"`
}
// handleStats scans all order logs and returns aggregated dashboard stats.
func (s *server) handleStats(w http.ResponseWriter, r *http.Request) {
matches, _ := filepath.Glob(filepath.Join(s.dataDir, "*.events.log"))
now := time.Now()
var totalOrders int
var totalRevenue, capturedRevenue, refundedRevenue int64
byStatus := map[order.Status]int{}
statusTotal := map[order.Status]int64{} // total amount per status
// Daily revenue for the last 30 days (keys are "2026-06-01" sortable strings).
dailyByDay := map[string]int64{}
for i := 0; i < 30; i++ {
day := now.AddDate(0, 0, -i).Format("2006-01-02")
dailyByDay[day] = 0
}
// Collect all order summaries, then sort by placedAt and take top 10.
var allOrders []orderSummary
var alerts []orderAlert
for _, m := range matches {
base := strings.TrimSuffix(filepath.Base(m), ".events.log")
raw, err := strconv.ParseUint(base, 10, 64)
if err != nil {
continue
}
g, err := s.pool.Get(r.Context(), raw)
if err != nil || g.Status == order.StatusNew {
continue
}
totalOrders++
byStatus[g.Status]++
statusTotal[g.Status] += g.TotalAmount.Int64()
totalRevenue += g.TotalAmount.Int64()
capturedRevenue += g.CapturedAmount.Int64()
refundedRevenue += g.RefundedAmount.Int64()
// Daily revenue — parse placedAt to extract date.
if g.PlacedAt != "" {
if placed, parseErr := time.Parse(time.RFC3339, g.PlacedAt); parseErr == nil {
dayKey := placed.Format("2006-01-02")
if _, ok := dailyByDay[dayKey]; ok {
dailyByDay[dayKey] += g.TotalAmount.Int64()
}
}
}
allOrders = append(allOrders, orderSummary{
OrderId: order.OrderId(raw).String(),
Reference: g.OrderReference,
Status: g.Status,
TotalAmount: g.TotalAmount.Int64(),
Currency: g.Currency,
PlacedAt: g.PlacedAt,
})
// Alerts.
if g.Status == order.StatusPending {
alerts = append(alerts, orderAlert{
Severity: "warn",
Message: "Payment still pending",
OrderId: order.OrderId(raw).String(),
})
}
if g.Status == order.StatusCancelled {
alerts = append(alerts, orderAlert{
Severity: "warn",
Message: "Order was cancelled",
OrderId: order.OrderId(raw).String(),
})
}
}
// Sort ALL orders by placedAt descending, take top 10 as "recent".
sort.Slice(allOrders, func(i, j int) bool {
return allOrders[i].PlacedAt > allOrders[j].PlacedAt
})
recent := allOrders
if len(recent) > 10 {
recent = recent[:10]
}
// Cap alerts
if len(alerts) > 50 {
alerts = alerts[:50]
}
// Build status buckets array with totals.
buckets := make([]statusBucket, 0, len(byStatus))
for st, cnt := range byStatus {
buckets = append(buckets, statusBucket{Status: st, Count: cnt, Total: statusTotal[st]})
}
sort.Slice(buckets, func(i, j int) bool {
return buckets[i].Count > buckets[j].Count
})
// Daily revenue as an ordered slice.
var dailyRev []dailyRevenue
for i := 29; i >= 0; i-- {
day := now.AddDate(0, 0, -i).Format("2006-01-02")
dailyRev = append(dailyRev, dailyRevenue{Date: day, Total: dailyByDay[day]})
}
writeJSON(w, http.StatusOK, orderStatsResponse{
OrdersTotal: totalOrders,
Revenue: revenueSnapshot{
Total: totalRevenue,
Captured: capturedRevenue,
Refunded: refundedRevenue,
Pending: totalRevenue - capturedRevenue - refundedRevenue,
},
ByStatus: buckets,
RecentOrders: recent,
DailyRevenue30: dailyRev,
Alerts: alerts,
})
}
// --- reads ----------------------------------------------------------------
func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
@@ -502,7 +710,6 @@ func selectTaxProvider() tax.Provider {
// the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for
// real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override).
func selectProvider(logger *slog.Logger) order.PaymentProvider {
if os.Getenv("PAYMENT_PROVIDER") == "stripe" {
key := os.Getenv("STRIPE_SECRET_KEY")
if key == "" {
@@ -515,6 +722,64 @@ func selectProvider(logger *slog.Logger) order.PaymentProvider {
return order.NewMockProvider()
}
// selectEmailSender picks the flow-email transport. Supports SMTP and
// MailerSend. When no sender is configured the hook still exists in
// capabilities but errors at run time if a flow tries to use it.
func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
kind := config.EnvString("ORDER_EMAIL_SENDER", "")
if kind == "" && os.Getenv("ORDER_EMAIL_SMTP_HOST") == "" {
return nil, nil
}
if kind == "" {
kind = "smtp"
}
fromAddr := strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_ADDRESS"))
if fromAddr == "" {
return nil, fmt.Errorf("ORDER_EMAIL_FROM_ADDRESS is required when email sender is configured")
}
from := mail.Address{
Name: strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_NAME")),
Address: fromAddr,
}
switch kind {
case "smtp":
sender, err := order.NewSMTPEmailSender(order.SMTPEmailSenderConfig{
Host: config.EnvString("ORDER_EMAIL_SMTP_HOST", ""),
Port: config.EnvInt("ORDER_EMAIL_SMTP_PORT", 587, func(n int) bool { return n > 0 }),
Username: os.Getenv("ORDER_EMAIL_SMTP_USERNAME"),
Password: os.Getenv("ORDER_EMAIL_SMTP_PASSWORD"),
DefaultFrom: from,
Timeout: config.EnvDuration("ORDER_EMAIL_SMTP_TIMEOUT", 10*time.Second),
})
if err != nil {
return nil, err
}
logger.Info("email sender enabled", "kind", "smtp", "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address)
return sender, nil
case "mailersend":
apiKey := os.Getenv("MAILERSEND_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("MAILERSEND_API_KEY is required for mailersend sender")
}
sender, err := order.NewMailerSendEmailSender(apiKey, from)
if err != nil {
return nil, err
}
logger.Info("email sender enabled", "kind", "mailersend", "from", from.Address)
return sender, nil
default:
return nil, fmt.Errorf("unsupported ORDER_EMAIL_SENDER %q (supported: smtp, mailersend)", kind)
}
}
func selectInventoryReservationService() (order.InventoryReservationService, error) {
return order.NewHTTPInventoryReservationService(config.EnvString("INVENTORY_URL", "http://localhost:8080"), nil)
}
// loadUCPOrderSigner loads the UCP ECDSA signing key from the path specified in
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
func loadUCPOrderSigner() *ucp.SigningConfig {
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"context"
"google.golang.org/protobuf/proto"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
)
// clusterAwareApplier is a ProfileApplier that chooses between an
// in-process pool call and a remote forward based on which replica
// currently owns the grain. It is the single seam at which the auth
// server (`customerauth.Server`) and the UCP customer handler
// (`ucp.CustomerServer`) ever speak to the grain pool — keeping the
// decision of "go to the authoritative owner" vs "spawn locally" in
// one place avoids the split-brain hazard introduced when multiple
// code paths (HTTP middleware + the handlers' own pool.Get) could each
// independently decide to spawn a stale or empty grain and broadcast
// conflicting ownership for it.
//
// The decision rule:
//
// - pool.OwnerHost(id) returns a remote host → forward Get/Apply
// to that host. The host holds the authoritative in-memory state.
// - pool.OwnerHost(id) returns no host → delegate to
// pool.Get / pool.Apply. On a local cache miss pool.Get spawns
// the grain from disk on this pod, broadcasts the new ownership,
// and returns the grain; this is the only code path that ever
// claims a grain on this pod.
//
// pool.Get's spawn path is unchanged on purpose: the disk-backed event
// log is the source of truth, and the local cache is just a projection
// rebuilt from it. The risk surface that the applicr layer removes is
// the HTTP-middleware fall-through that re-entered the same pool.Get
// from a different code path and duplicated the decision.
type clusterAwareApplier struct {
pool *actor.SimpleGrainPool[profile.ProfileGrain]
}
// Get returns the current state of grain id, preferring the
// authoritative remote owner when one is registered. With no remote
// owner the pool spawns the grain locally (from the disk-backed event
// log), takes ownership, and returns the grain.
func (a *clusterAwareApplier) Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error) {
if owner, ok := a.pool.OwnerHost(id); ok {
return owner.Get(ctx, id)
}
return a.pool.Get(ctx, id)
}
// Apply mutates grain id with messages, forwarding to the authoritative
// remote owner when one is registered. With no remote owner the pool
// spawns the grain locally, applies the mutation, takes ownership, and
// returns the mutated state — its listeners (including the AMQP
// mutation feed) fire from this pod.
func (a *clusterAwareApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error) {
if owner, ok := a.pool.OwnerHost(id); ok {
return owner.Apply(ctx, id, msgs...)
}
return a.pool.Apply(ctx, id, msgs...)
}
+1 -60
View File
@@ -1,68 +1,9 @@
package main
import (
"log"
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
// GetDiscovery returns the k8s pod-watcher that finds peer profile replicas, or
// nil when POD_IP is unset (single-node / local dev — clustering disabled). The
// watch is scoped to this pod's own namespace so it only needs a namespaced Role
// (pods: get/list/watch), not a cluster-wide role.
func GetDiscovery() discovery.Discovery {
if podIp == "" {
return nil
}
config, kerr := rest.InClusterConfig()
if kerr != nil {
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
}
client, err := kubernetes.NewForConfig(config)
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
timeout := int64(30)
return discovery.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
LabelSelector: "actor-pool=profile",
TimeoutSeconds: &timeout,
})
}
// UseDiscovery starts the watch loop that adds/removes peer hosts from the pool
// as profile replicas become ready or go away. A nil discovery (POD_IP unset)
// logs and returns, leaving the pool in single-node mode.
func UseDiscovery(pool discovery.DiscoveryTarget) {
go func(hw discovery.Discovery) {
if hw == nil {
log.Print("No discovery service available")
return
}
ch, err := hw.Watch()
if err != nil {
log.Printf("Discovery error: %v", err)
return
}
for evt := range ch {
if evt.Host == "" {
continue
}
switch evt.IsReady {
case false:
if pool.IsKnown(evt.Host) {
log.Printf("Host %s is not ready, removing", evt.Host)
pool.RemoveHost(evt.Host)
}
default:
if !pool.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
pool.AddRemoteHost(evt.Host)
}
}
}
}(GetDiscovery())
discovery.StartK8sDiscovery(podIp, "actor-pool=profile", 30, pool)
}
+115 -4
View File
@@ -8,9 +8,11 @@ import (
"log"
"net"
"net/http"
"net/mail"
"os"
"os/signal"
"path/filepath"
"strings"
"time"
"git.k6n.net/mats/go-cart-actor/internal/customerauth"
@@ -55,6 +57,32 @@ func buildAuthStorage(ctx context.Context, profileDir string) (customerauth.Cred
// authSecret returns the HMAC key for customer session cookies. It reads
// CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and
// warns that issued sessions will not survive a restart (fine for dev).
// selectAuthNotifier picks the transactional auth-message sender. When
// MAILERSEND_API_KEY is set it returns a MailerSend-backed notifier; otherwise
// it returns nil (the server defaults to LogNotifier, which is fine for dev).
func selectAuthNotifier() customerauth.Notifier {
apiKey := os.Getenv("MAILERSEND_API_KEY")
if apiKey == "" {
return nil
}
fromAddr := strings.TrimSpace(os.Getenv("PROFILE_EMAIL_FROM_ADDRESS"))
if fromAddr == "" {
log.Print("warning: MAILERSEND_API_KEY set but PROFILE_EMAIL_FROM_ADDRESS is empty — falling back to LogNotifier")
return nil
}
from := mail.Address{
Name: strings.TrimSpace(os.Getenv("PROFILE_EMAIL_FROM_NAME")),
Address: fromAddr,
}
n, err := customerauth.NewMailerSendNotifier(apiKey, from)
if err != nil {
log.Printf("warning: mailersend notifier: %v — falling back to LogNotifier", err)
return nil
}
log.Print("customer-auth: using MailerSend notifier for verification and reset emails")
return n
}
func authSecret() []byte {
if v := os.Getenv("CUSTOMER_AUTH_SECRET"); v != "" {
return []byte(v)
@@ -70,6 +98,13 @@ func authSecret() []byte {
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
// profileMetrics is the shared prometheus instrumentation for the
// profile actor pool and event log. Built once at process start; the
// actor package's touch sites are nil-safe so a test binary could pass
// nil. Package-level so tests in this package can construct a pool
// without re-registering (cart/checkout use the same pattern).
var profileMetrics = actor.NewMetrics("profile")
// loadUCPProfileSigner loads the UCP ECDSA signing key from the path specified in
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
func loadUCPProfileSigner() *ucp.SigningConfig {
@@ -98,10 +133,12 @@ func main() {
log.Printf("warning: could not create profile data directory %s: %v", profileDir, err)
}
diskStorage := actor.NewDiskStorage[profile.ProfileGrain](profileDir, reg)
diskStorage.SetMetrics(profileMetrics)
poolConfig := actor.GrainPoolConfig[profile.ProfileGrain]{
MutationRegistry: reg,
Storage: diskStorage,
Metrics: profileMetrics,
Spawn: func(ctx context.Context, id uint64) (actor.Grain[profile.ProfileGrain], error) {
ret := profile.NewProfileGrain(id, time.Now())
err := diskStorage.LoadEvents(ctx, id, ret)
@@ -159,6 +196,31 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
// Data Retention (C5) GDPR Purge
retentionDays := config.EnvInt("PROFILE_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 })
if retentionDays > 0 {
purgeInterval := config.EnvDuration("PROFILE_PURGE_INTERVAL", 24*time.Hour)
log.Printf("profile: data retention enabled. Retaining profiles for %d days, purging every %v", retentionDays, purgeInterval)
go func() {
ticker := time.NewTicker(purgeInterval)
defer ticker.Stop()
// Run immediately on startup
runPurge(ctx, diskStorage, pool, retentionDays)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runPurge(ctx, diskStorage, pool, retentionDays)
}
}
}()
} else {
log.Print("profile: data retention / GDPR purge disabled (PROFILE_RETENTION_DAYS not set or <= 0)")
}
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
if err != nil {
log.Fatalf("Unable to start otel %v", err)
@@ -172,11 +234,37 @@ func main() {
// CUSTOMER_AUTH_SECRET is all they need to work across replicas.
credStore, limiter := buildAuthStorage(ctx, profileDir)
// Email index: in-memory email→profileID map so the order service can
// look up email preferences without scanning all profiles. Built from
// the event log on disk at startup, then kept live by the UCP handlers.
emailIndex := profile.NewProfileEmailIndex()
stateStorage := actor.NewState(reg)
profile.BuildEmailIndex(profileDir, stateStorage, reg, emailIndex)
// UCP Customer REST adapter
auditLogPath := filepath.Join(profileDir, "audit.log")
customerUCP := ucp.CustomerHandler(pool, auditLogPath, credStore)
if signer := loadUCPProfileSigner(); signer != nil {
customerUCP = ucp.WithSigning(customerUCP, signer)
// Session signer is shared by the auth server (HMAC verifier for the
// "sid" cookie); NewSigner is stateless once built so a single
// instance can be reused across the auth server's NewServer call.
sessionSigner := customerauth.NewSigner(authSecret())
// Cluster-aware ProfileApplier: routes Get/Apply to the replica that
// currently owns the grain. With a remote owner we forward there so
// the request reads the authoritative in-memory state; with no owner
// we let pool.Get / pool.Apply spawn the grain locally from the
// disk-backed event log and broadcast ownership. Centralising this
// choice in the applicr (rather than HTTP middleware) prevents the
// split-brain hazard where a non-owner pod reads a stale or empty
// grain from disk and caches it under its own (conflicting)
// ownership.
applier := &clusterAwareApplier{pool: pool}
customerUCP := ucp.CustomerHandler(applier, auditLogPath,
ucp.WithEmailIndex(emailIndex),
ucp.WithCredentialDeleter(credStore),
)
if ucpSigner := loadUCPProfileSigner(); ucpSigner != nil {
customerUCP = ucp.WithSigning(customerUCP, ucpSigner)
log.Print("ucp customer signing enabled")
}
// StripPrefix is required because the sub-mux (CustomerHandler) registers
@@ -185,7 +273,9 @@ func main() {
mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP))
// Customer auth: password signup/login + session cookies + identity linking.
authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0, customerauth.Options{
authNotifier := selectAuthNotifier()
authHandler := customerauth.NewServer(credStore, applier, sessionSigner, 0, customerauth.Options{
Notifier: authNotifier,
Limiter: limiter,
BaseURL: os.Getenv("CUSTOMER_AUTH_BASE_URL"),
RequireVerifiedEmail: os.Getenv("CUSTOMER_AUTH_REQUIRE_VERIFIED") == "true",
@@ -252,3 +342,24 @@ func main() {
stop()
}
}
func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[profile.ProfileGrain], pool *actor.SimpleGrainPool[profile.ProfileGrain], retentionDays int) {
log.Printf("profile: starting data retention purge...")
retention := time.Duration(retentionDays) * 24 * time.Hour
localIds := make(map[uint64]bool)
for _, id := range pool.GetLocalIds() {
localIds[id] = true
}
isGrainActive := func(id uint64) bool {
return localIds[id]
}
purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive)
if err != nil {
log.Printf("profile: data retention purge failed: %v", err)
} else {
log.Printf("profile: data retention purge completed. Purged %d expired profile logs", purged)
}
}
+1 -1
View File
@@ -196,7 +196,7 @@ func buildProfile() ucp.ProfileData {
}},
"dev.ucp.shopping.authentication": {{
Version: ucpVersion,
Spec: "https://ucp.dev/2026-04-08/specification/authentication",
Spec: "https://ucp.dev/2026-04-08/specification/identity-linking",
Schema: authURL,
Description: "Customer authentication — email+password registration and login with HMAC-signed session cookies, logout, current-customer lookup, email verification, and password reset. Failed logins and reset requests are rate-limited.",
Operations: []ucp.OpDef{
+102 -3
View File
@@ -5,7 +5,7 @@
{
"id": "volymrabatt",
"name": "Volymrabatt",
"description": "Automatisk volymrabatt baserad på varukorgens totalsumma (inkl. moms). Belopp i öre: 20 00040 000 kr → 5%, 40 00060 000 kr → 9%, 60 00090 000 kr → 14%.",
"description": "Automatisk volymrabatt baserad på varukorgens totalsumma (inkl. moms). 20 000+ kr → 5%, 40 000+ kr → 9%, 60 000+ kr → 14%.",
"status": "active",
"priority": 100,
"startDate": "2024-01-01",
@@ -28,7 +28,7 @@
"tiers": [
{ "minTotal": 2000000, "maxTotal": 4000000, "percent": 5 },
{ "minTotal": 4000000, "maxTotal": 6000000, "percent": 9 },
{ "minTotal": 6000000, "maxTotal": 9000000, "percent": 14 }
{ "minTotal": 6000000, "maxTotal": 0, "percent": 14 }
]
},
"label": "Volymrabatt 514%"
@@ -38,7 +38,106 @@
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"createdBy": "mats.tornberg@gmail.com",
"tags": ["volume", "volymrabatt", "test"]
"tags": ["volume", "volymrabatt"]
},
{
"id": "fri-frakt",
"name": "Fri frakt över 10 000 kr",
"description": "Fri frakt när varukorgens totalsumma (inkl. moms) överstiger 10 000 kr.",
"status": "active",
"priority": 50,
"startDate": "2024-01-01",
"endDate": null,
"conditions": [
{
"id": "min-cart-total",
"type": "cart_total",
"operator": ">=",
"value": 1000000,
"label": "Minst 10 000 kr i varukorgen"
}
],
"actions": [
{
"id": "fri-frakt-action",
"type": "free_shipping",
"value": 0,
"label": "Fri frakt"
}
],
"usageCount": 0,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"createdBy": "mats.tornberg@gmail.com",
"tags": ["shipping", "free-shipping"]
},
{
"id": "fonster-10-pct",
"name": "10% rabatt på fönster",
"description": "10% rabatt på alla fönster i varukorgen.",
"status": "active",
"priority": 200,
"startDate": "2024-01-01",
"endDate": null,
"conditions": [
{
"id": "cat-windows",
"type": "product_category",
"operator": "in",
"value": ["Fönster"]
}
],
"actions": [
{
"id": "fonster-pct",
"type": "percentage_discount",
"value": 10,
"label": "10% rabatt på fönster"
}
],
"usageCount": 0,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"createdBy": "mats.tornberg@gmail.com",
"tags": ["windows", "category"]
},
{
"id": "sommar15",
"name": "15% rabatt med kod SOMMAR15",
"description": "15% rabatt på hela köpet när rabattkoden SOMMAR15 används. Gäller vid köp över 5 000 kr under sommaren.",
"status": "active",
"priority": 150,
"startDate": "2024-06-01",
"endDate": null,
"conditions": [
{
"id": "min-cart-total",
"type": "cart_total",
"operator": ">=",
"value": 500000,
"label": "Minst 5 000 kr i varukorgen"
},
{
"id": "coupon-code",
"type": "coupon_code",
"operator": "=",
"value": "SOMMAR15",
"label": "Ange koden SOMMAR15"
}
],
"actions": [
{
"id": "sommar15-pct",
"type": "percentage_discount",
"value": 15,
"label": "15% rabatt med kod"
}
],
"usageCount": 0,
"createdAt": "2024-06-01T00:00:00Z",
"updatedAt": "2024-06-01T00:00:00Z",
"createdBy": "mats.tornberg@gmail.com",
"tags": ["seasonal", "coupon", "summer"]
}
]
}
+18
View File
@@ -78,6 +78,8 @@ spec:
env:
- name: CART_DIR
value: "/data/cart-actor"
- name: PROMOTIONS_FILE
value: "/data/cart-actor/promotions.json"
- name: CHECKOUT_DIR
value: "/data/checkout-actor"
- name: TZ
@@ -186,6 +188,8 @@ spec:
env:
- name: CART_DIR
value: "/data/cart-actor"
- name: PROMOTIONS_FILE
value: "/data/promotions.json"
- name: CHECKOUT_DIR
value: "/data/checkout-actor"
- name: TZ
@@ -230,6 +234,10 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: CART_RETENTION_DAYS
value: "30"
- name: CART_PURGE_INTERVAL
value: "24h"
---
apiVersion: apps/v1
kind: Deployment
@@ -308,6 +316,8 @@ spec:
memory: "70Mi"
cpu: "1200m"
env:
- name: PROMOTIONS_FILE
value: "/data/promotions.json"
- name: TZ
value: "Europe/Stockholm"
- name: REDIS_ADDRESS
@@ -350,6 +360,10 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: CART_RETENTION_DAYS
value: "30"
- name: CART_PURGE_INTERVAL
value: "24h"
---
kind: Service
apiVersion: v1
@@ -484,6 +498,10 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: CHECKOUT_RETENTION_DAYS
value: "30"
- name: CHECKOUT_PURGE_INTERVAL
value: "24h"
---
kind: Service
apiVersion: v1
+1
View File
@@ -63,6 +63,7 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailersend/mailersend-go v1.4.0
github.com/mailru/easyjson v0.9.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
+11
View File
@@ -127,7 +127,10 @@ github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwm
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
@@ -137,6 +140,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/lyft/protoc-gen-star/v2 v2.0.4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk=
github.com/mailersend/mailersend-go v1.4.0 h1:8IXN3HXoyKh6qG0IyTESedEjlaYsZfT0XnV2k7XzjvE=
github.com/mailersend/mailersend-go v1.4.0/go.mod h1:4MeiOnzmjWCsXRNdjg6NGzsijsVrmQ8E/T003/ystQU=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdempsky/unconvert v0.0.0-20250216222326-4a038b3d31f5/go.mod h1:mVCHGHs8r8jnrZ2ammcv8ySbhG2+rEPXegFmdNA51GI=
@@ -152,8 +157,14 @@ github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtX
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
+1 -1
View File
@@ -71,7 +71,7 @@
"gridPos": { "x": 18, "y": 1, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "connected_remotes" }
{ "refId": "A", "expr": "cart_connected_remotes" }
],
"options": {
"colorMode": "value",
+86
View File
@@ -0,0 +1,86 @@
package customerauth
import (
"context"
"fmt"
"log"
"net/mail"
"strings"
"time"
"github.com/mailersend/mailersend-go"
)
// MailerSendNotifier delivers transactional auth messages (email verification
// and password reset) through the MailerSend API. It implements the Notifier
// interface and is a drop-in replacement for LogNotifier in production.
type MailerSendNotifier struct {
client *mailersend.Mailersend
from mail.Address
}
// NewMailerSendNotifier creates a new MailerSend-backed notifier. The apiKey
// is the MailerSend API token (MAILERSEND_API_KEY env var). The from address
// must include at least an email; the name portion is optional.
func NewMailerSendNotifier(apiKey string, from mail.Address) (*MailerSendNotifier, error) {
if strings.TrimSpace(apiKey) == "" {
return nil, fmt.Errorf("mailersend notifier: missing API key")
}
if strings.TrimSpace(from.Address) == "" {
return nil, fmt.Errorf("mailersend notifier: missing from address")
}
return &MailerSendNotifier{
client: mailersend.NewMailersend(apiKey),
from: from,
}, nil
}
// SendEmailVerification sends the verification link to the customer's email
// address. Errors are logged but not returned (best-effort delivery matches the
// signature of the Notifier interface).
func (n *MailerSendNotifier) SendEmailVerification(email, link string) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := n.send(ctx, email,
"Verify your email address",
fmt.Sprintf("Click the link to verify your email address:\n\n%s", link),
fmt.Sprintf(`<p>Click <a href="%s">here</a> to verify your email address.</p>`, link),
); err != nil {
log.Printf("customerauth: send verification to %s: %v", email, err)
}
}
// SendPasswordReset sends the password-reset link to the customer's email.
// Errors are logged but not returned.
func (n *MailerSendNotifier) SendPasswordReset(email, link string) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := n.send(ctx, email,
"Reset your password",
fmt.Sprintf("Click the link to reset your password:\n\n%s\n\nIf you didn't request this, ignore this email.", link),
fmt.Sprintf(`<p>Click <a href="%s">here</a> to reset your password.</p><p>If you didn't request this, ignore this email.</p>`, link),
); err != nil {
log.Printf("customerauth: send password reset to %s: %v", email, err)
}
}
func (n *MailerSendNotifier) send(ctx context.Context, to, subject, textBody, htmlBody string) error {
message := n.client.Email.NewMessage()
message.SetFrom(mailersend.From{
Name: n.from.Name,
Email: n.from.Address,
})
message.SetRecipients([]mailersend.Recipient{
{Email: to},
})
message.SetSubject(subject)
message.SetText(textBody)
if htmlBody != "" {
message.SetHTML(htmlBody)
}
_, err := n.client.Email.Send(ctx, message)
if err != nil {
return fmt.Errorf("mailersend: %w", err)
}
return nil
}
+16
View File
@@ -163,6 +163,13 @@ type resetCompleteRequest struct {
Password string `json:"password"`
}
// EmailPreferencesResponse mirrors the UCP email preferences type for the
// customer-facing API.
type EmailPreferencesResponse struct {
OrderEmails *bool `json:"orderEmails,omitempty"`
MarketingEmails *bool `json:"marketingEmails,omitempty"`
}
// CustomerResponse mirrors the UCP customer projection (kept local to avoid an
// import cycle with internal/ucp). The password hash is never included.
type CustomerResponse struct {
@@ -176,6 +183,9 @@ type CustomerResponse struct {
Addresses []AddressResponse `json:"addresses"`
Orders []OrderRef `json:"orders"`
// EmailPreferences carries the customer's current email opt-in/out state.
EmailPreferences *EmailPreferencesResponse `json:"emailPreferences,omitempty"`
// EmailVerified reflects whether the email-verification flow has completed.
EmailVerified bool `json:"emailVerified"`
}
@@ -554,6 +564,12 @@ func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse {
for _, o := range g.Orders {
resp.Orders = append(resp.Orders, OrderRef{OrderReference: o.OrderReference, Status: o.Status})
}
if g.EmailPreferences != nil {
resp.EmailPreferences = &EmailPreferencesResponse{
OrderEmails: g.EmailPreferences.OrderEmails,
MarketingEmails: g.EmailPreferences.MarketingEmails,
}
}
return resp
}
+5
View File
@@ -174,6 +174,11 @@ func (s *CredentialStore) persistLocked() error {
return fmt.Errorf("customerauth: temp file: %w", err)
}
tmpName := tmp.Name()
if err := tmp.Chmod(0644); err != nil {
tmp.Close()
os.Remove(tmpName)
return fmt.Errorf("customerauth: chmod temp: %w", err)
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpName)
+11
View File
@@ -189,11 +189,22 @@ func cartGrainToResponse(id string, g *cart.CartGrain) CartResponse {
Items: make([]CartItem, 0, len(g.Items)),
Totals: Totals{Currency: g.Currency},
Status: "active",
AppliedPromotions: g.AppliedPromotions,
}
if g.CheckoutStatus != nil && *g.CheckoutStatus != "" {
resp.Status = string(*g.CheckoutStatus)
}
// Per-line evaluation breakdown. The canonical promotion pipeline
// (pkg/promotions.PromotionService.EvaluateAndApply) populates this
// field on the grain itself after every successful mutation, so the
// conversion just reads g.EvaluatedItems verbatim — no recompute at
// response time. Same shape /promotions/evaluate-with-cart returns
// (both reference cart.EvaluatedItem and cart.MapEvaluatedItems),
// so a line verified via GET /ucp/v1/carts/{id} byte-matches the
// preview from /promotions/evaluate-with-cart.
resp.EvaluatedItems = g.EvaluatedItems
for _, it := range g.Items {
if it == nil {
continue
+100
View File
@@ -229,6 +229,106 @@ func TestCartResponse_Conversion(t *testing.T) {
}
}
// TestCartResponse_EvaluatedItems verifies that the per-line breakdown the
// canonical promotion pipeline populates on CartGrain.EvaluatedItems flows
// verbatim into the UCP CartResponse's EvaluatedItems field. Conversion is a
// straight pass-through now — we hand-construct the grain's EvaluatedItems
// field with the expected values to lock down the wire shape independent of
// the math MapEvaluatedItems applies (which has its own tests in pkg/cart).
func TestCartResponse_EvaluatedItems(t *testing.T) {
id := mustParseID("ABCD")
g := cart.NewCartGrain(id, time.UnixMilli(1000000))
g.Currency = "SEK"
g.Items = append(g.Items, &cart.CartItem{
Sku: "promo-1",
Quantity: 2,
Price: *mustPrice(10000, 2500),
TotalPrice: *mustPrice(20000, 5000),
Tax: 2500,
OrgPrice: mustPrice(12000, 3000),
Discount: mustPrice(2000, 500),
Meta: &cart.ItemMeta{Name: "Promo Item"},
})
g.TotalPrice = mustPrice(20000, 5000)
// In the live cart, pkg/promotions.PromotionService.EvaluateAndApply
// populates g.EvaluatedItems via cart.MapEvaluatedItems after every
// successful mutation. The conversion test only cares that whatever
// the grain carries projects into resp.EvaluatedItems unchanged — so
// the input is the canonical shape, not the math's output.
g.EvaluatedItems = []cart.EvaluatedItem{{
Sku: "promo-1",
Name: "Promo Item",
Quantity: 2,
PriceIncVat: 10000,
OrgPriceIncVat: 12000,
DiscountIncVat: 2000,
EffectivePriceIncVat: 9000,
EffectiveTotalIncVat: 18000,
}}
resp := cartGrainToResponse(g.Id.String(), g)
if len(resp.EvaluatedItems) != 1 {
t.Fatalf("expected 1 evaluated item, got %d", len(resp.EvaluatedItems))
}
ev := resp.EvaluatedItems[0]
if ev.Sku != "promo-1" {
t.Fatalf("expected sku 'promo-1', got %q", ev.Sku)
}
if ev.Name != "Promo Item" {
t.Fatalf("expected name 'Promo Item', got %q", ev.Name)
}
if ev.Quantity != 2 {
t.Fatalf("expected qty 2, got %d", ev.Quantity)
}
if ev.PriceIncVat != 10000 {
t.Fatalf("expected priceIncVat 10000, got %d", ev.PriceIncVat)
}
if ev.OrgPriceIncVat != 12000 {
t.Fatalf("expected orgPriceIncVat 12000, got %d", ev.OrgPriceIncVat)
}
if ev.DiscountIncVat != 2000 {
t.Fatalf("expected discountIncVat 2000, got %d", ev.DiscountIncVat)
}
if ev.EffectiveTotalIncVat != 18000 {
t.Fatalf("expected effectiveTotalIncVat 18000, got %d", ev.EffectiveTotalIncVat)
}
if ev.EffectivePriceIncVat != 9000 {
t.Fatalf("expected effectivePriceIncVat 9000, got %d", ev.EffectivePriceIncVat)
}
}
// TestCartResponse_EvaluatedItems_MapEquivalence confirms that
// cart.MapEvaluatedItems produces the same shape the test's hand-built
// []cart.EvaluatedItem entries contain, given matching inputs. This locks
// the conversion path's output to the canonical pipeline's output — if
// either side starts omitting or renaming a field, this fails.
func TestCartResponse_EvaluatedItems_MapEquivalence(t *testing.T) {
id := mustParseID("ABCD")
g := cart.NewCartGrain(id, time.UnixMilli(1000000))
g.Currency = "SEK"
g.Items = append(g.Items, &cart.CartItem{
Sku: "promo-1",
Quantity: 2,
Price: *mustPrice(10000, 2500),
TotalPrice: *mustPrice(20000, 5000),
Tax: 2500,
OrgPrice: mustPrice(12000, 3000),
Discount: mustPrice(2000, 500),
Meta: &cart.ItemMeta{Name: "Promo Item"},
})
g.TotalPrice = mustPrice(20000, 5000)
canonical := cart.MapEvaluatedItems(g)
if len(canonical) != 1 {
t.Fatalf("expected 1 evaluated item from MapEvaluatedItems, got %d", len(canonical))
}
e := canonical[0]
if e.DiscountIncVat != 2000 || e.EffectiveTotalIncVat != 18000 || e.EffectivePriceIncVat != 9000 {
t.Fatalf("MapEvaluatedItems math off: %+v", e)
}
}
func mustPrice(incVat int64, totalVat int64) *cart.Price {
return &cart.Price{
IncVat: money.Cents(incVat),
+52 -2
View File
@@ -451,6 +451,8 @@ type orderLine struct {
Quantity int32 `json:"quantity"`
UnitPrice money.Cents `json:"unitPrice"`
TaxRate int32 `json:"taxRate"`
DropShip bool `json:"drop_ship,omitempty"`
Location string `json:"location,omitempty"`
}
type orderPayment struct {
@@ -540,6 +542,16 @@ func (c *OrderHTTPClient) CreateOrder(ctx context.Context, checkoutID uint64, g
// buildOrderLines converts a checkout grain's cart items and delivery selections
// into order lines, matching the format expected by the order service.
func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
hasFreeShipping := false
if g.CartState != nil {
for _, ap := range g.CartState.AppliedPromotions {
if ap.Type == "free_shipping" && !ap.Pending {
hasFreeShipping = true
break
}
}
}
lines := make([]orderLine, 0, len(g.CartState.Items)+len(g.Deliveries))
for _, it := range g.CartState.Items {
if it == nil {
@@ -549,6 +561,10 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
if it.Meta != nil {
name = it.Meta.Name
}
location := ""
if it.StoreId != nil {
location = *it.StoreId
}
lines = append(lines, orderLine{
Reference: it.Sku,
Sku: it.Sku,
@@ -558,10 +574,19 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
// CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale
// (2500 = 25%), so the rate passes through unchanged.
TaxRate: int32(it.Tax),
DropShip: it.DropShip,
Location: location,
})
}
for _, d := range g.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
if d == nil {
continue
}
unitPrice := d.Price.IncVat
if hasFreeShipping {
unitPrice = 0
}
if unitPrice <= 0 && !hasFreeShipping {
continue
}
lines = append(lines, orderLine{
@@ -569,10 +594,35 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
Sku: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: d.Price.IncVat,
UnitPrice: unitPrice,
TaxRate: 2500, // 25% in basis points
})
}
// Reflected applied promotions as negative discount lines
if g.CartState != nil {
for _, ap := range g.CartState.AppliedPromotions {
if ap.Pending {
continue
}
discountVal := int64(0)
if ap.Discount != nil {
discountVal = ap.Discount.IncVat.Int64()
}
if discountVal <= 0 {
continue
}
lines = append(lines, orderLine{
Reference: "promo-" + ap.PromotionId,
Sku: "promo-" + ap.PromotionId,
Name: "Promotion: " + ap.Name,
Quantity: 1,
UnitPrice: money.Cents(-discountVal),
TaxRate: 2500, // default standard tax rate (25%) in basis points
})
}
}
return lines
}
+77
View File
@@ -19,6 +19,7 @@ type CustomerServer struct {
applier ProfileApplier
deleter CredentialDeleter
auditLogPath string
emailIndex *profile.ProfileEmailIndex // optional, maintained by mutations
}
// NewCustomerServer builds a UCP REST adapter over a profile grain pool.
@@ -30,6 +31,33 @@ func NewCustomerServer(applier ProfileApplier, auditLogPath string, deleter ...C
return &CustomerServer{applier: applier, deleter: del, auditLogPath: auditLogPath}
}
// SetEmailIndex attaches a ProfileEmailIndex that is updated by every
// customer mutation (create, update, delete).
func (s *CustomerServer) SetEmailIndex(ix *profile.ProfileEmailIndex) {
s.emailIndex = ix
}
// indexProfileAfterMutation reads the grain and updates the email index.
func (s *CustomerServer) indexProfileAfterMutation(ctx context.Context, id uint64) {
if s.emailIndex == nil {
return
}
g, err := s.applier.Get(ctx, id)
if err != nil {
return
}
prefs := g.EmailPreferences
// Copy so the index holds its own snapshot (the grain is pooled).
if prefs != nil {
cp := &profile.EmailPreferences{
OrderEmails: prefs.OrderEmails,
MarketingEmails: prefs.MarketingEmails,
}
prefs = cp
}
s.emailIndex.Set(id, g.Email, prefs)
}
// ---------------------------------------------------------------------------
// UCP Customer endpoints
// ---------------------------------------------------------------------------
@@ -61,6 +89,11 @@ func (s *CustomerServer) handleCreateCustomer(w http.ResponseWriter, r *http.Req
AvatarUrl: req.AvatarUrl,
}
if req.EmailPreferences != nil {
msg.OrderEmails = req.EmailPreferences.OrderEmails
msg.MarketingEmails = req.EmailPreferences.MarketingEmails
}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create customer: "+err.Error())
return
@@ -72,6 +105,8 @@ func (s *CustomerServer) handleCreateCustomer(w http.ResponseWriter, r *http.Req
return
}
s.indexProfileAfterMutation(r.Context(), id)
writeJSON(w, http.StatusCreated, customerGrainToResponse(rawId.String(), g))
}
@@ -132,6 +167,11 @@ func (s *CustomerServer) handleUpdateCustomer(w http.ResponseWriter, r *http.Req
AvatarUrl: req.AvatarUrl,
}
if req.EmailPreferences != nil {
msg.OrderEmails = req.EmailPreferences.OrderEmails
msg.MarketingEmails = req.EmailPreferences.MarketingEmails
}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update customer: "+err.Error())
return
@@ -143,9 +183,36 @@ func (s *CustomerServer) handleUpdateCustomer(w http.ResponseWriter, r *http.Req
return
}
s.indexProfileAfterMutation(r.Context(), id)
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
}
// handleGetCustomerByEmail handles GET /customers/by-email/{email} — looks up
// the customer profile from the email index and returns the customer response.
// Returns 404 when the email is not found.
func (s *CustomerServer) handleGetCustomerByEmail(w http.ResponseWriter, r *http.Request) {
email := r.PathValue("email")
if email == "" || s.emailIndex == nil {
writeError(w, http.StatusNotFound, "customer not found")
return
}
entry, ok := s.emailIndex.Lookup(email)
if !ok {
writeError(w, http.StatusNotFound, "customer not found")
return
}
g, err := s.applier.Get(r.Context(), entry.ID)
if err != nil {
writeError(w, http.StatusNotFound, "customer not found")
return
}
writeJSON(w, http.StatusOK, customerGrainToResponse(profile.ProfileId(entry.ID).String(), g))
}
// handleAddAddress handles POST /customers/{id}/addresses.
func (s *CustomerServer) handleAddAddress(w http.ResponseWriter, r *http.Request) {
id, ok := parseCustomerID(r)
@@ -333,6 +400,8 @@ func (s *CustomerServer) handleDeleteCustomer(w http.ResponseWriter, r *http.Req
// Log audit trail to stdout (GDPR requirement)
fmt.Printf("AUDIT: GDPR right to erasure executed for customer profile id %d\n", id)
s.indexProfileAfterMutation(r.Context(), id)
w.WriteHeader(http.StatusNoContent)
}
@@ -477,5 +546,13 @@ func customerGrainToResponse(id string, g *profile.ProfileGrain) CustomerRespons
})
}
if g.EmailPreferences != nil {
prefs := &EmailPreferencesResponse{
OrderEmails: g.EmailPreferences.OrderEmails,
MarketingEmails: g.EmailPreferences.MarketingEmails,
}
resp.EmailPreferences = prefs
}
return resp
}
+1 -1
View File
@@ -519,7 +519,7 @@ func TestDeleteCustomer(t *testing.T) {
id := testProfileID(t, applier)
deleter := &testCredentialDeleter{}
auditPath := filepath.Join(t.TempDir(), "audit.log")
handler := CustomerHandler(applier, auditPath, deleter)
handler := CustomerHandler(applier, auditPath, WithCredentialDeleter(deleter))
// Set up some profile data
pid, _ := profile.ParseProfileId(id)
+30 -3
View File
@@ -1,6 +1,10 @@
package ucp
import "net/http"
import (
"net/http"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
)
// ---------------------------------------------------------------------------
// Order HTTP handler mounting
@@ -86,11 +90,33 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
//
// mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer))
func CustomerHandler(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) http.Handler {
s := NewCustomerServer(applier, auditLogPath, deleter...)
// CustomerHandlerOption configures a CustomerHandler with optional dependencies.
type CustomerHandlerOption func(s *CustomerServer)
// WithEmailIndex attaches a ProfileEmailIndex that the customer handler
// updates on every mutation and uses for the GET /by-email/{email} route.
func WithEmailIndex(ix *profile.ProfileEmailIndex) CustomerHandlerOption {
return func(s *CustomerServer) {
s.SetEmailIndex(ix)
}
}
// WithCredentialDeleter attaches a credential deleter for GDPR erasure.
func WithCredentialDeleter(deleter CredentialDeleter) CustomerHandlerOption {
return func(s *CustomerServer) {
s.deleter = deleter
}
}
func CustomerHandler(applier ProfileApplier, auditLogPath string, opts ...CustomerHandlerOption) http.Handler {
s := NewCustomerServer(applier, auditLogPath)
for _, opt := range opts {
opt(s)
}
mux := http.NewServeMux()
mux.HandleFunc("POST /", s.handleCreateCustomer)
mux.HandleFunc("GET /{id}", s.handleGetCustomer)
mux.HandleFunc("GET /by-email/{email}", s.handleGetCustomerByEmail)
mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer)
mux.HandleFunc("DELETE /{id}", s.handleDeleteCustomer)
mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress)
@@ -154,6 +180,7 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler {
customerSrv := NewCustomerServer(opts.ProfileApplier, opts.ProfileAuditLog, opts.CredentialDeleter)
mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer)
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
mux.HandleFunc("GET /customers/by-email/{email}", customerSrv.handleGetCustomerByEmail)
mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer)
mux.HandleFunc("DELETE /customers/{id}", customerSrv.handleDeleteCustomer)
mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress)
+30
View File
@@ -121,11 +121,27 @@ type VoucherInput struct {
}
// CartResponse is the UCP cart response body.
//
// EvaluatedItems mirrors the per-line breakdown the canonical promotion
// pipeline (pkg/promotions.PromotionService.EvaluateAndApply) populates
// on the grain after every successful mutation — see
// pkg/cart/evaluated_item.go for the EvaluatedItem type and the rounding
// rules. The shape and field order match the live cart grain's own
// EvaluatedItems field and the response from /promotions/evaluate-with-cart
// (both reference cart.EvaluatedItem and cart.MapEvaluatedItems), so a
// line verified via GET /ucp/v1/carts/{id} byte-matches the preview.
//
// Kept parallel to Items rather than merged into CartItem to preserve
// the UCP wire contract — existing UCP clients keep working unchanged
// and the per-line breakdown can grow on the EvaluatedItem type without
// churning the standard item shape.
type CartResponse struct {
Id string `json:"id"`
Items []CartItem `json:"items,omitempty"`
EvaluatedItems []cart.EvaluatedItem `json:"evaluatedItems,omitempty"`
Totals Totals `json:"totals"`
Status string `json:"status"`
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"`
}
// CartItem is the UCP wire format for a cart line item in responses.
@@ -335,6 +351,12 @@ type OrderLineEntry struct {
// UCP Customer types
// ---------------------------------------------------------------------------
// EmailPreferencesResponse is the UCP wire format for email preference toggles.
type EmailPreferencesResponse struct {
OrderEmails *bool `json:"orderEmails,omitempty"`
MarketingEmails *bool `json:"marketingEmails,omitempty"`
}
// CustomerResponse is the UCP customer profile response body.
type CustomerResponse struct {
Id string `json:"id"`
@@ -345,6 +367,7 @@ type CustomerResponse struct {
Currency string `json:"currency,omitempty"`
AvatarUrl string `json:"avatarUrl,omitempty"`
Addresses []AddressResponse `json:"addresses,omitempty"`
EmailPreferences *EmailPreferencesResponse `json:"emailPreferences,omitempty"`
}
// AddressResponse is the UCP wire format for an address.
@@ -363,6 +386,12 @@ type AddressResponse struct {
IsDefaultBilling bool `json:"isDefaultBilling"`
}
// EmailPreferencesInput is the body for updating a customer's email preferences.
type EmailPreferencesInput struct {
OrderEmails *bool `json:"orderEmails,omitempty"`
MarketingEmails *bool `json:"marketingEmails,omitempty"`
}
// CustomerUpdateRequest is the body for PUT /customers/{id}.
type CustomerUpdateRequest struct {
Name *string `json:"name,omitempty"`
@@ -371,6 +400,7 @@ type CustomerUpdateRequest struct {
Language *string `json:"language,omitempty"`
Currency *string `json:"currency,omitempty"`
AvatarUrl *string `json:"avatarUrl,omitempty"`
EmailPreferences *EmailPreferencesInput `json:"emailPreferences,omitempty"`
}
// AddAddressRequest is the body for POST /customers/{id}/addresses.
+363 -36
View File
@@ -7,22 +7,38 @@ import (
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"google.golang.org/protobuf/proto"
)
const defaultWriterIdleTTL = 5 * time.Minute
type QueueEvent struct {
TimeStamp time.Time
Message proto.Message
}
// diskLogWriter holds one append-only log file kept open between writes.
type diskLogWriter struct {
mu sync.Mutex
file *os.File
lastUsed time.Time
purged bool // set by purgeIdleWriters before closing; getWriter re-checks after acquiring mu
}
type DiskStorage[V any] struct {
*StateStorage
path string
done chan struct{}
queue *sync.Map // map[uint64][]QueueEvent
dirOnce sync.Once
dirErr error
writersMu sync.Mutex
writers map[uint64]*diskLogWriter
writerIdleTTL time.Duration
}
type LogStorage[V any] interface {
@@ -32,10 +48,219 @@ type LogStorage[V any] interface {
}
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
return &DiskStorage[V]{
s := &DiskStorage[V]{
StateStorage: NewState(registry),
path: path,
done: make(chan struct{}),
writers: make(map[uint64]*diskLogWriter),
writerIdleTTL: defaultWriterIdleTTL,
}
go s.purgeLoop()
go s.filesExistingLoop()
return s
}
// filesExistingLoop refreshes the EventLogFilesExisting gauge every
// 30 seconds. The gauge represents the count of .events.log files on
// disk, which diverges from the open-writer count whenever the idle
// purge closes a writer but the file is still kept (the .log file is
// not deleted by the idle purge). Periodic re-listing is cheap (one
// os.ReadDir per tick) and is the only way the gauge can pick up files
// created or removed out of band (e.g. GDPR purge, or the init-time
// rebuild of a grain that already has a log).
func (s *DiskStorage[V]) filesExistingLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.done:
return
case <-ticker.C:
s.refreshFilesExisting()
}
}
}
// refreshFilesExisting counts .events.log files in the storage dir
// and writes the value to the EventLogFilesExisting gauge. Safe to
// call concurrently — os.ReadDir is atomic for our purposes (the dir
// is owned by this pod, mutations go through the writersMu lock).
// No-op when the dir does not exist yet (cold start) or when metrics
// are disabled (s.StateStorage.metrics == nil).
func (s *DiskStorage[V]) refreshFilesExisting() {
if s.StateStorage.metrics == nil {
return
}
entries, err := os.ReadDir(s.path)
if err != nil {
// Dir may not exist yet on a cold start, or briefly during a
// retention-driven directory recreation. Leave the gauge
// untouched in that case — the next tick will catch up.
return
}
count := 0
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".events.log") {
count++
}
}
s.StateStorage.metrics.EventLogFilesExisting.Set(float64(count))
}
func (s *DiskStorage[V]) ensureDir() error {
s.dirOnce.Do(func() {
s.dirErr = os.MkdirAll(s.path, 0o755)
})
return s.dirErr
}
// getWriter returns an open append handle for id. The caller must unlock w.mu
// when finished writing.
// SetMetrics attaches a Metrics instance to the embedded StateStorage
// so every append / replay / unknown-type path has access to the
// same event-log counters. The metrics struct is shared by reference;
// do not mutate its fields from outside. Pass nil to disable
// instrumentation (used by tests that do not care about Prometheus).
func (s *DiskStorage[V]) SetMetrics(m *Metrics) {
s.StateStorage.metrics = m
}
func (s *DiskStorage[V]) recordAppend(n int) {
m := s.StateStorage.metrics
if m == nil {
return
}
m.EventLogAppends.Inc()
if n > 0 {
m.EventLogBytesWritten.Add(float64(n))
}
m.EventLogLastAppendUnix.Set(float64(time.Now().Unix()))
}
func (s *DiskStorage[V]) writeEvents(id uint64, events []QueueEvent) error {
if len(events) == 0 {
return nil
}
w, err := s.getWriter(id)
if err != nil {
return err
}
defer w.mu.Unlock()
for _, evt := range events {
n, err := s.Append(w.file, evt.Message, evt.TimeStamp)
if err != nil {
// A partial write still counts the bytes that DID land on
// disk, so the throughput gauge is honest about a mid-write
// failure.
s.recordAppend(n)
return err
}
s.recordAppend(n)
}
w.lastUsed = time.Now()
return nil
}
func (s *DiskStorage[V]) writeMutations(id uint64, msgs ...proto.Message) error {
if len(msgs) == 0 {
return nil
}
w, err := s.getWriter(id)
if err != nil {
return err
}
defer w.mu.Unlock()
now := time.Now()
for _, m := range msgs {
n, err := s.Append(w.file, m, now)
if err != nil {
s.recordAppend(n)
return err
}
s.recordAppend(n)
}
w.lastUsed = time.Now()
return nil
}
func (s *DiskStorage[V]) purgeLoop() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-s.done:
return
case <-ticker.C:
s.purgeIdleWriters()
}
}
}
func (s *DiskStorage[V]) purgeIdleWriters() {
cutoff := time.Now().Add(-s.writerIdleTTL)
s.writersMu.Lock()
defer s.writersMu.Unlock()
for id, w := range s.writers {
w.mu.Lock()
if w.lastUsed.Before(cutoff) {
w.purged = true
delete(s.writers, id)
if err := w.file.Close(); err != nil {
log.Printf("failed to close idle event log %d: %v", id, err)
}
}
w.mu.Unlock()
}
}
func (s *DiskStorage[V]) getWriter(id uint64) (*diskLogWriter, error) {
if err := s.ensureDir(); err != nil {
return nil, err
}
for {
s.writersMu.Lock()
w, ok := s.writers[id]
if !ok {
fh, err := os.OpenFile(s.logPath(id), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
s.writersMu.Unlock()
return nil, err
}
w = &diskLogWriter{file: fh, lastUsed: time.Now()}
s.writers[id] = w
}
s.writersMu.Unlock()
w.mu.Lock()
if !w.purged {
w.lastUsed = time.Now()
return w, nil
}
// Writer was purged by purgeIdleWriters between our map lookup and acquiring w.mu.
// Release it and retry — the map no longer contains this entry, so the next
// iteration will create a fresh writer.
w.mu.Unlock()
}
}
func (s *DiskStorage[V]) closeWriters() {
s.writersMu.Lock()
defer s.writersMu.Unlock()
for id, w := range s.writers {
w.mu.Lock()
w.purged = true
if err := w.file.Close(); err != nil {
log.Printf("failed to close event log %d: %v", id, err)
}
delete(s.writers, id)
w.mu.Unlock()
}
}
@@ -55,26 +280,23 @@ func (s *DiskStorage[V]) SaveLoop(duration time.Duration) {
}
func (s *DiskStorage[V]) save() {
if s.queue == nil {
return
}
carts := 0
lines := 0
s.queue.Range(func(key, value any) bool {
id := key.(uint64)
path := s.logPath(id)
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Printf("failed to open event log file: %v", err)
qe, ok := value.([]QueueEvent)
if !ok {
s.queue.Delete(id)
return true
}
defer fh.Close()
if qe, ok := value.([]QueueEvent); ok {
for _, msg := range qe {
if err := s.Append(fh, msg.Message, msg.TimeStamp); err != nil {
log.Printf("failed to append event to log file: %v", err)
}
lines++
}
if err := s.writeEvents(id, qe); err != nil {
log.Printf("failed to append events for grain %d: %v", id, err)
return true
}
lines += len(qe)
carts++
s.queue.Delete(id)
return true
@@ -88,6 +310,43 @@ func (s *DiskStorage[V]) logPath(id uint64) string {
return filepath.Join(s.path, fmt.Sprintf("%d.events.log", id))
}
// loadReplayMetrics times the replay, records duration, and tracks
// handler errors. It is shared by LoadEvents and LoadEventsFunc. The
// returned `onMessage` closure bumps EventLogMutationErrors on any
// non-nil error from registry.Apply, and the deferred function bumps
// EventLogReplayTotal / Failures / Duration on the overall return.
func (s *DiskStorage[V]) loadReplayMetrics() (onMessage func(err error), done func(err error)) {
m := s.StateStorage.metrics
if m == nil {
noop := func(error) {}
return noop, noop
}
start := time.Now()
onMessage = func(err error) {
if err != nil {
m.EventLogMutationErrors.Inc()
}
}
done = func(err error) {
if err != nil {
m.EventLogReplayFailures.Inc()
return
}
m.EventLogReplayTotal.Inc()
m.EventLogReplayDuration.Observe(time.Since(start).Seconds())
}
return onMessage, done
}
// recordReplayFailure bumps EventLogReplayFailures when the open
// step fails before the Load loop runs (and therefore before the
// loadReplayMetrics closure is in scope). Nil-safe.
func (s *DiskStorage[V]) recordReplayFailure() {
if m := s.StateStorage.metrics; m != nil {
m.EventLogReplayFailures.Inc()
}
}
func (s *DiskStorage[V]) LoadEventsFunc(ctx context.Context, id uint64, grain Grain[V], condition func(msg proto.Message, index int, timeStamp time.Time) bool) error {
path := s.logPath(id)
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
@@ -97,16 +356,25 @@ func (s *DiskStorage[V]) LoadEventsFunc(ctx context.Context, id uint64, grain Gr
fh, err := os.Open(path)
if err != nil {
s.recordReplayFailure()
return fmt.Errorf("open replay file: %w", err)
}
defer fh.Close()
trackApply, done := s.loadReplayMetrics()
index := 0
return s.Load(fh, func(msg proto.Message, when time.Time) {
loadErr := s.Load(fh, func(msg proto.Message, when time.Time) {
if condition(msg, index, when) {
s.registry.Apply(ctx, grain, msg)
results, _ := s.registry.Apply(ctx, grain, msg)
for _, r := range results {
if r.Error != nil {
trackApply(r.Error)
}
}
}
index++
})
done(loadErr)
return loadErr
}
func (s *DiskStorage[V]) LoadEvents(ctx context.Context, id uint64, grain Grain[V]) error {
@@ -118,18 +386,28 @@ func (s *DiskStorage[V]) LoadEvents(ctx context.Context, id uint64, grain Grain[
fh, err := os.Open(path)
if err != nil {
s.recordReplayFailure()
return fmt.Errorf("open replay file: %w", err)
}
defer fh.Close()
return s.Load(fh, func(msg proto.Message, _ time.Time) {
s.registry.Apply(ctx, grain, msg)
trackApply, done := s.loadReplayMetrics()
loadErr := s.Load(fh, func(msg proto.Message, _ time.Time) {
results, _ := s.registry.Apply(ctx, grain, msg)
for _, r := range results {
if r.Error != nil {
trackApply(r.Error)
}
}
})
done(loadErr)
return loadErr
}
func (s *DiskStorage[V]) Close() {
if s.queue != nil {
s.save()
}
s.closeWriters()
close(s.done)
}
@@ -145,25 +423,74 @@ func (s *DiskStorage[V]) AppendMutations(id uint64, msg ...proto.Message) error
}
s.queue.Store(id, queue)
return nil
} else {
path := s.logPath(id)
// Ensure the parent directory exists (e.g. first write to a new PVC mount).
// MkdirAll is a no-op when the directory already exists, so it's safe to
// call on every AppendMutations without extra stat overhead.
if err := os.MkdirAll(s.path, 0755); err != nil {
log.Printf("failed to create event log directory %s: %v", s.path, err)
return err
}
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Printf("failed to open event log file: %v", err)
return err
}
defer fh.Close()
for _, m := range msg {
err = s.Append(fh, m, time.Now())
}
return err
return s.writeMutations(id, msg...)
}
func (s *DiskStorage[V]) openWriterCount() int {
s.writersMu.Lock()
defer s.writersMu.Unlock()
return len(s.writers)
}
func (s *DiskStorage[V]) PurgeOlderThan(ctx context.Context, retention time.Duration, isGrainActive func(id uint64) bool) (int, error) {
if err := s.ensureDir(); err != nil {
return 0, err
}
files, err := os.ReadDir(s.path)
if err != nil {
return 0, fmt.Errorf("read storage dir: %w", err)
}
cutoff := time.Now().Add(-retention)
purgedCount := 0
for _, entry := range files {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".events.log") {
continue
}
var id uint64
_, err := fmt.Sscanf(name, "%d.events.log", &id)
if err != nil {
continue
}
info, err := entry.Info()
if err != nil {
log.Printf("failed to get file info for %s: %v", name, err)
continue
}
if info.ModTime().Before(cutoff) {
// Check if grain is active in memory
if isGrainActive != nil && isGrainActive(id) {
continue
}
// Check if we have an active writer open
s.writersMu.Lock()
_, hasWriter := s.writers[id]
s.writersMu.Unlock()
if hasWriter {
continue
}
// Delete the file
fullPath := filepath.Join(s.path, name)
if err := os.Remove(fullPath); err != nil {
log.Printf("failed to delete expired cart log %s: %v", fullPath, err)
} else {
purgedCount++
}
}
}
return purgedCount, nil
}
+403
View File
@@ -0,0 +1,403 @@
package actor
import (
"context"
"os"
"sync"
"testing"
"time"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"google.golang.org/protobuf/proto"
)
type diskTestGrain struct {
testState
id uint64
accessed time.Time
}
func (g *diskTestGrain) GetId() uint64 { return g.id }
func (g *diskTestGrain) GetLastAccess() time.Time { return g.accessed }
func (g *diskTestGrain) GetLastChange() time.Time { return g.accessed }
func (g *diskTestGrain) GetCurrentState() (*testState, error) {
return &g.testState, nil
}
func diskTestRegistry(t *testing.T) MutationRegistry {
t.Helper()
reg := NewMutationRegistry()
reg.RegisterMutations(NewMutation(
func(_ *diskTestGrain, _ *cart_messages.AddItem) error { return nil },
))
return reg
}
func TestDiskStorageAppendAndReplay(t *testing.T) {
reg := diskTestRegistry(t)
storage := NewDiskStorage[testState](t.TempDir(), reg)
t.Cleanup(storage.Close)
const id = uint64(99)
events := []proto.Message{
&cart_messages.AddItem{ItemId: 1, Quantity: 2},
&cart_messages.AddItem{ItemId: 2, Quantity: 1},
}
for _, evt := range events {
if err := storage.AppendMutations(id, evt); err != nil {
t.Fatalf("append: %v", err)
}
}
grain := &diskTestGrain{id: id, accessed: time.Now()}
if err := storage.LoadEvents(context.Background(), id, grain); err != nil {
t.Fatalf("replay: %v", err)
}
}
func TestDiskStorageReusesOpenWriter(t *testing.T) {
reg := diskTestRegistry(t)
storage := NewDiskStorage[testState](t.TempDir(), reg)
t.Cleanup(storage.Close)
const id = uint64(7)
msg := &cart_messages.AddItem{ItemId: 1, Quantity: 1}
if err := storage.AppendMutations(id, msg); err != nil {
t.Fatal(err)
}
if got := storage.openWriterCount(); got != 1 {
t.Fatalf("open writers after first append = %d, want 1", got)
}
if err := storage.AppendMutations(id, msg); err != nil {
t.Fatal(err)
}
if got := storage.openWriterCount(); got != 1 {
t.Fatalf("open writers after second append = %d, want 1", got)
}
}
func TestDiskStorageIdleWriterClosed(t *testing.T) {
reg := diskTestRegistry(t)
storage := NewDiskStorage[testState](t.TempDir(), reg)
storage.writerIdleTTL = 20 * time.Millisecond
t.Cleanup(storage.Close)
if err := storage.AppendMutations(1, &cart_messages.AddItem{ItemId: 1, Quantity: 1}); err != nil {
t.Fatal(err)
}
if storage.openWriterCount() != 1 {
t.Fatal("expected one open writer")
}
time.Sleep(30 * time.Millisecond)
storage.purgeIdleWriters()
if storage.openWriterCount() != 0 {
t.Fatalf("expected idle writer to be closed, got %d open", storage.openWriterCount())
}
}
func TestDiskStorageSaveLoopUsesWriterCache(t *testing.T) {
reg := diskTestRegistry(t)
storage := NewDiskStorage[testState](t.TempDir(), reg)
t.Cleanup(storage.Close)
go storage.SaveLoop(20 * time.Millisecond)
const id = uint64(55)
if err := storage.AppendMutations(id, &cart_messages.AddItem{ItemId: 3, Quantity: 4}); err != nil {
t.Fatal(err)
}
deadline := time.After(500 * time.Millisecond)
for {
if storage.openWriterCount() >= 1 {
break
}
select {
case <-deadline:
t.Fatal("timed out waiting for SaveLoop flush")
default:
time.Sleep(10 * time.Millisecond)
}
}
grain := &diskTestGrain{id: id, accessed: time.Now()}
if err := storage.LoadEvents(context.Background(), id, grain); err != nil {
t.Fatalf("replay after SaveLoop: %v", err)
}
}
func TestDiskStorageConcurrentAppendSameID(t *testing.T) {
reg := diskTestRegistry(t)
storage := NewDiskStorage[testState](t.TempDir(), reg)
t.Cleanup(storage.Close)
const id = uint64(88)
const workers = 16
var wg sync.WaitGroup
wg.Add(workers)
for i := range workers {
go func(itemID uint32) {
defer wg.Done()
_ = storage.AppendMutations(id, &cart_messages.AddItem{ItemId: itemID, Quantity: 1})
}(uint32(i + 1))
}
wg.Wait()
grain := &diskTestGrain{id: id, accessed: time.Now()}
if err := storage.LoadEvents(context.Background(), id, grain); err != nil {
t.Fatalf("replay: %v", err)
}
}
func TestDiskStorageCloseFlushesSaveLoop(t *testing.T) {
reg := diskTestRegistry(t)
storage := NewDiskStorage[testState](t.TempDir(), reg)
// Start the buffered SaveLoop but give it a long interval so it does NOT
// flush during the test — only Close() should trigger the flush. A brief
// sleep lets the goroutine schedule and set s.queue before we append.
go storage.SaveLoop(10 * time.Minute)
time.Sleep(5 * time.Millisecond)
const id = uint64(101)
if err := storage.AppendMutations(id, &cart_messages.AddItem{ItemId: 7, Quantity: 3}); err != nil {
t.Fatal(err)
}
// Verify event is queued but NOT yet on disk (no writer should be open
// because the buffered path only opens files inside SaveLoop → save()).
if got := storage.openWriterCount(); got != 0 {
t.Fatalf("expected 0 open writers before flush, got %d", got)
}
// Close flushes the queue then closes writers.
storage.Close()
// After Close(), the event should be persisted. Open a fresh storage and
// replay to verify.
fresh := NewDiskStorage[testState](storage.path, reg)
t.Cleanup(fresh.Close)
grain := &diskTestGrain{id: id, accessed: time.Now()}
if err := fresh.LoadEvents(context.Background(), id, grain); err != nil {
t.Fatalf("replay after Close flush: %v", err)
}
}
func TestDiskStorageMultipleGrainsIndependent(t *testing.T) {
reg := diskTestRegistry(t)
storage := NewDiskStorage[testState](t.TempDir(), reg)
t.Cleanup(storage.Close)
ids := []uint64{10, 20, 30}
for i, id := range ids {
evt := &cart_messages.AddItem{ItemId: uint32(i + 1), Quantity: int32(i + 1)}
if err := storage.AppendMutations(id, evt); err != nil {
t.Fatalf("append id=%d: %v", id, err)
}
}
// Verify each grain has exactly one writer open (shared per id).
if got := storage.openWriterCount(); got != len(ids) {
t.Fatalf("open writers = %d, want %d", got, len(ids))
}
// Replay each grain and verify the correct event was stored.
for _, id := range ids {
ctx := context.Background()
grain := &diskTestGrain{id: id, accessed: time.Now()}
if err := storage.LoadEvents(ctx, id, grain); err != nil {
t.Fatalf("replay id=%d: %v", id, err)
}
}
}
func TestDiskStorageLoadEventsNoFile(t *testing.T) {
reg := diskTestRegistry(t)
storage := NewDiskStorage[testState](t.TempDir(), reg)
t.Cleanup(storage.Close)
// No AppendMutations — no file should exist.
grain := &diskTestGrain{id: 999, accessed: time.Now()}
if err := storage.LoadEvents(context.Background(), 999, grain); err != nil {
t.Fatalf("LoadEvents on non-existent file: %v", err)
}
}
func TestDiskStorageWriteAfterIdlePurge(t *testing.T) {
reg := diskTestRegistry(t)
storage := NewDiskStorage[testState](t.TempDir(), reg)
t.Cleanup(storage.Close)
// Set a very short idle TTL so the writer is evicted quickly.
storage.writerIdleTTL = 10 * time.Millisecond
const id = uint64(202)
msg := &cart_messages.AddItem{ItemId: 1, Quantity: 1}
// First write opens a writer.
if err := storage.AppendMutations(id, msg); err != nil {
t.Fatal(err)
}
if got := storage.openWriterCount(); got != 1 {
t.Fatalf("open writers after first write = %d, want 1", got)
}
// Wait for idle TTL to expire and purge.
time.Sleep(20 * time.Millisecond)
storage.purgeIdleWriters()
if got := storage.openWriterCount(); got != 0 {
t.Fatalf("open writers after purge = %d, want 0", got)
}
// Second write must create a new writer through the retry loop in getWriter.
if err := storage.AppendMutations(id, msg); err != nil {
t.Fatalf("write after purge: %v", err)
}
if got := storage.openWriterCount(); got != 1 {
t.Fatalf("open writers after write-before-close = %d, want 1", got)
}
// Replay must see both events.
grain := &diskTestGrain{id: id, accessed: time.Now()}
if err := storage.LoadEvents(context.Background(), id, grain); err != nil {
t.Fatalf("replay after purge: %v", err)
}
}
func TestDiskStorageCloseWithoutSaveLoop(t *testing.T) {
reg := diskTestRegistry(t)
storage := NewDiskStorage[testState](t.TempDir(), reg)
// Verify Close() on a storage that never started SaveLoop works cleanly.
const id = uint64(303)
if err := storage.AppendMutations(id, &cart_messages.AddItem{ItemId: 1, Quantity: 1}); err != nil {
t.Fatal(err)
}
// Close() — queue is nil so it skips save(), goes straight to closeWriters().
storage.Close()
// A fresh storage should be able to replay the persisted event.
fresh := NewDiskStorage[testState](storage.path, reg)
t.Cleanup(fresh.Close)
grain := &diskTestGrain{id: id, accessed: time.Now()}
if err := fresh.LoadEvents(context.Background(), id, grain); err != nil {
t.Fatalf("replay after close: %v", err)
}
}
// TestDiskStorageConcurrentWritesDifferentIDs verifies that concurrent writes to
// different grains don't interfere with each other, and all events are persisted.
func TestDiskStorageConcurrentWritesDifferentIDs(t *testing.T) {
reg := diskTestRegistry(t)
storage := NewDiskStorage[testState](t.TempDir(), reg)
t.Cleanup(storage.Close)
const grainCount = 32
var wg sync.WaitGroup
wg.Add(grainCount)
for id := range uint64(grainCount) {
go func(grainID uint64) {
defer wg.Done()
for i := range 5 {
_ = storage.AppendMutations(grainID, &cart_messages.AddItem{ItemId: uint32(i + 1), Quantity: 1})
}
}(id + 1)
}
wg.Wait()
// Replay each grain and verify.
for id := range uint64(grainCount) {
grainID := id + 1
ctx := context.Background()
grain := &diskTestGrain{id: grainID, accessed: time.Now()}
if err := storage.LoadEvents(ctx, grainID, grain); err != nil {
t.Fatalf("replay grain %d: %v", grainID, err)
}
}
}
func TestDiskStoragePurge(t *testing.T) {
reg := diskTestRegistry(t)
dir := t.TempDir()
storage := NewDiskStorage[testState](dir, reg)
t.Cleanup(storage.Close)
ctx := context.Background()
msg := &cart_messages.AddItem{ItemId: 1, Quantity: 1}
// Write mutations to create event logs for 1, 2, 3, 4
ids := []uint64{1, 2, 3, 4}
for _, id := range ids {
if err := storage.AppendMutations(id, msg); err != nil {
t.Fatalf("append %d: %v", id, err)
}
}
// Force save to disk
storage.save()
// 1. Grain 1: Old modification time, inactive, no writer -> should be purged
path1 := storage.logPath(1)
oldTime := time.Now().Add(-10 * 24 * time.Hour)
if err := os.Chtimes(path1, oldTime, oldTime); err != nil {
t.Fatal(err)
}
// We must close the writer for grain 1, 2, 4 to make them eligible for purging (no open writer)
storage.writersMu.Lock()
for _, id := range []uint64{1, 2, 4} {
if w, ok := storage.writers[id]; ok {
w.purged = true
w.file.Close()
delete(storage.writers, id)
}
}
storage.writersMu.Unlock()
// 2. Grain 2: Old mod time, but marked active -> should NOT be purged
path2 := storage.logPath(2)
if err := os.Chtimes(path2, oldTime, oldTime); err != nil {
t.Fatal(err)
}
// 3. Grain 3: Old mod time, but has open writer -> should NOT be purged
path3 := storage.logPath(3)
if err := os.Chtimes(path3, oldTime, oldTime); err != nil {
t.Fatal(err)
}
// 4. Grain 4: Recent mod time -> should NOT be purged
path4 := storage.logPath(4)
activeGrains := map[uint64]bool{2: true}
isGrainActive := func(id uint64) bool {
return activeGrains[id]
}
purged, err := storage.PurgeOlderThan(ctx, 5*24*time.Hour, isGrainActive)
if err != nil {
t.Fatalf("PurgeOlderThan failed: %v", err)
}
if purged != 1 {
t.Errorf("Expected exactly 1 purged file, got %d", purged)
}
// Verify file status
if _, err := os.Stat(path1); !os.IsNotExist(err) {
t.Error("Expected grain 1 log to be deleted")
}
if _, err := os.Stat(path2); err != nil {
t.Error("Expected grain 2 log to exist (active)")
}
if _, err := os.Stat(path3); err != nil {
t.Error("Expected grain 3 log to exist (active writer)")
}
if _, err := os.Stat(path4); err != nil {
t.Error("Expected grain 4 log to exist (recent)")
}
}
+24 -4
View File
@@ -19,8 +19,13 @@ type GrainPool[V any] interface {
OwnerHost(id uint64) (Host[V], bool)
Hostname() string
TakeOwnership(id uint64)
HandleOwnershipChange(host string, ids []uint64) error
HandleRemoteExpiry(host string, ids []uint64) error
// HandleOwnershipChange processes a remote first-touch ownership
// claim. lastChanges is the parallel []int64 of UnixNano stamps from
// the announcer; -1 entries fall back to the pre-arbitration
// "always accept remote" behaviour so mixed-version rollouts
// remain safe.
HandleOwnershipChange(host string, ids []uint64, lastChanges []int64) error
HandleRemoteExpiry(host string, ids []uint64, lastChanges []int64) error
Negotiate(otherHosts []string)
GetLocalIds() []uint64
IsHealthy() bool
@@ -32,7 +37,13 @@ type GrainPool[V any] interface {
// Host abstracts a remote node capable of proxying cart requests.
type Host[V any] interface {
AnnounceExpiry(ids []uint64)
// AnnounceExpiry broadcasts per-id eviction decisions. lastChanges is
// a parallel []int64 of UnixNano stamps taken at the moment the
// grain was dropped from the local cache; it is informational on
// this path (the receiver just removes the id from its remoteOwners
// map) but kept in the wire signature for symmetry with
// AnnounceOwnership.
AnnounceExpiry(ids []uint64, lastChanges []int64)
Negotiate(otherHosts []string) ([]string, error)
Name() string
Proxy(id uint64, w http.ResponseWriter, r *http.Request, customBody io.Reader) (bool, error)
@@ -42,5 +53,14 @@ type Host[V any] interface {
Close() error
Ping() bool
IsHealthy() bool
AnnounceOwnership(ownerHost string, ids []uint64)
// AnnounceOwnership broadcasts a first-touch ownership claim.
// lastChanges is a parallel []int64 of UnixNano stamps of the
// announcing pod's local grain's GetLastChange() at the moment of
// broadcast; receivers use it as the first-spawn-wins oracle for
// concurrent cold-cache first-touch (smaller stamp = older spawn =
// owns the grain; on equal stamps the lexicographically smaller
// hostname wins). A stamp of -1 means "no local grain to back this
// id" — receivers treat that as legacy/no-arbitration and accept
// the remote claim as before.
AnnounceOwnership(ownerHost string, ids []uint64, lastChanges []int64)
}
+2 -2
View File
@@ -88,7 +88,7 @@ func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.
)
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
err := s.pool.HandleOwnershipChange(req.Host, req.Ids, req.LastChanges)
if err != nil {
span.RecordError(err)
return &messages.OwnerChangeAck{
@@ -138,7 +138,7 @@ func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.Exp
)
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids, req.LastChanges)
if err != nil {
span.RecordError(err)
}
+2 -2
View File
@@ -41,8 +41,8 @@ func (m *mockGrainPool) TakeOwnership(id uint64) {}
func (m *mockGrainPool) Hostname() string { return "test-host" }
func (m *mockGrainPool) HandleOwnershipChange(host string, ids []uint64) error { return nil }
func (m *mockGrainPool) HandleRemoteExpiry(host string, ids []uint64) error { return nil }
func (m *mockGrainPool) HandleOwnershipChange(host string, ids []uint64, _ []int64) error { return nil }
func (m *mockGrainPool) HandleRemoteExpiry(host string, ids []uint64, _ []int64) error { return nil }
func (m *mockGrainPool) Negotiate(hosts []string) {}
func (m *mockGrainPool) GetLocalIds() []uint64 { return []uint64{} }
func (m *mockGrainPool) RemoveHost(host string) {}
+216
View File
@@ -0,0 +1,216 @@
package actor
import (
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// Metrics bundles every prometheus metric the actor package emits. It is
// constructed per service (cart / checkout / profile / order) with a
// service-specific prefix so the metric names do not collide when more
// than one actor-pool service is scraped into the same Prometheus.
//
// All touch sites are nil-safe: the pool / storage / registry code wraps
// each counter / histogram call in a nil-check on the *Metrics pointer,
// so the existing tests (which construct a pool or storage without
// metrics) keep working without a forced dependency on this struct.
//
// Metric names match the panels in grafana_dashboard_cart.json exactly.
// The dashboard had `connected_remotes` (no prefix) as a typo; the
// source-of-truth metric exported here is `<prefix>_connected_remotes`
// and the dashboard JSON is updated in the same change to match.
type Metrics struct {
// Pool — set by SimpleGrainPool.
ActiveGrains prometheus.Gauge
GrainsInPool prometheus.Gauge
PoolUsage prometheus.Gauge
ConnectedRemotes prometheus.Gauge
GrainSpawnedTotal prometheus.Counter
GrainLookupsTotal prometheus.Counter
MutationsTotal prometheus.Counter
MutationFailuresTotal prometheus.Counter
MutationLatency prometheus.Histogram
RemoteNegotiationTotal prometheus.Counter
// Event log — set by DiskStorage and StateStorage.
EventLogAppends prometheus.Counter
EventLogBytesWritten prometheus.Counter
EventLogFilesExisting prometheus.Gauge
EventLogLastAppendUnix prometheus.Gauge
EventLogReplayTotal prometheus.Counter
EventLogReplayFailures prometheus.Counter
EventLogReplayDuration prometheus.Histogram
EventLogUnknownTypes prometheus.Counter
EventLogMutationErrors prometheus.Counter
}
// NewMetrics registers every actor metric in the default Prometheus
// registry (the one promhttp.Handler() exposes on /metrics) with the
// service-specific prefix. The prefix is the same string the binary uses
// as its service name (e.g. "cart", "checkout", "profile", "order"),
// which keeps the metric names aligned with the per-service labels in
// grafana_dashboard_cart.json.
func NewMetrics(prefix string) *Metrics {
return &Metrics{
ActiveGrains: promauto.NewGauge(prometheus.GaugeOpts{
Name: prefix + "_active_grains",
Help: "Number of grains currently held in the local pool.",
}),
GrainsInPool: promauto.NewGauge(prometheus.GaugeOpts{
Name: prefix + "_grains_in_pool",
Help: "Alias of active_grains: the dashboard shows both as a sanity check.",
}),
PoolUsage: promauto.NewGauge(prometheus.GaugeOpts{
Name: prefix + "_grain_pool_usage",
Help: "Local pool fill ratio: len(grains) / poolSize (range 0..1).",
}),
ConnectedRemotes: promauto.NewGauge(prometheus.GaugeOpts{
Name: prefix + "_connected_remotes",
Help: "Number of remote actor-pool hosts currently connected via gRPC.",
}),
GrainSpawnedTotal: promauto.NewCounter(prometheus.CounterOpts{
Name: prefix + "_grain_spawned_total",
Help: "Total number of grains spawned (loaded from disk or freshly created).",
}),
GrainLookupsTotal: promauto.NewCounter(prometheus.CounterOpts{
Name: prefix + "_grain_lookups_total",
Help: "Total number of pool.Get calls (state reads).",
}),
MutationsTotal: promauto.NewCounter(prometheus.CounterOpts{
Name: prefix + "_mutations_total",
Help: "Total number of mutations applied across all grains (one per proto message, not per call).",
}),
MutationFailuresTotal: promauto.NewCounter(prometheus.CounterOpts{
Name: prefix + "_mutation_failures_total",
Help: "Total number of pool.Apply calls that returned an error.",
}),
// Latency buckets cover the typical pool.Apply (handler + registry
// apply + storage enqueue): sub-ms in the hot path, hundreds of ms
// under load. Tweak if a hot mutation type moves outside this range.
MutationLatency: promauto.NewHistogram(prometheus.HistogramOpts{
Name: prefix + "_mutation_latency_seconds",
Help: "Wall-clock duration of a pool.Apply call (lock + spawn + handler + storage enqueue).",
Buckets: prometheus.ExponentialBuckets(0.0005, 2, 14), // 0.5ms ... ~8s
}),
RemoteNegotiationTotal: promauto.NewCounter(prometheus.CounterOpts{
Name: prefix + "_remote_negotiation_total",
Help: "Total number of cluster-negotiation rounds initiated by this pod.",
}),
EventLogAppends: promauto.NewCounter(prometheus.CounterOpts{
Name: prefix + "_event_log_appends_total",
Help: "Total number of event-log line appends (one per persisted mutation message).",
}),
EventLogBytesWritten: promauto.NewCounter(prometheus.CounterOpts{
Name: prefix + "_event_log_bytes_written_total",
Help: "Total bytes written to per-grain .events.log files.",
}),
EventLogFilesExisting: promauto.NewGauge(prometheus.GaugeOpts{
Name: prefix + "_event_log_files_existing",
Help: "Number of per-grain .events.log files currently on disk in the storage directory.",
}),
EventLogLastAppendUnix: promauto.NewGauge(prometheus.GaugeOpts{
Name: prefix + "_event_log_last_append_unix",
Help: "Unix timestamp of the last successful event-log append.",
}),
EventLogReplayTotal: promauto.NewCounter(prometheus.CounterOpts{
Name: prefix + "_event_log_replay_total",
Help: "Total number of successful event-log replays (one per grain loaded from disk).",
}),
EventLogReplayFailures: promauto.NewCounter(prometheus.CounterOpts{
Name: prefix + "_event_log_replay_failures_total",
Help: "Total number of failed event-log replays (open error, JSON parse error, or handler error).",
}),
EventLogReplayDuration: promauto.NewHistogram(prometheus.HistogramOpts{
Name: prefix + "_event_log_replay_duration_seconds",
Help: "Wall-clock time to replay a single grain's event log from disk.",
Buckets: prometheus.ExponentialBuckets(0.001, 2, 14), // 1ms ... ~16s
}),
EventLogUnknownTypes: promauto.NewCounter(prometheus.CounterOpts{
Name: prefix + "_event_log_unknown_types_total",
Help: "Total number of event-log entries with a type the registry does not recognise.",
}),
EventLogMutationErrors: promauto.NewCounter(prometheus.CounterOpts{
Name: prefix + "_event_log_mutation_errors_total",
Help: "Total number of mutation-handler errors observed during event-log replay.",
}),
}
}
// MutationStarted returns a closure to be deferred at the top of
// pool.Apply so the latency observation / counters fire exactly once
// per call regardless of which return path is taken. mutationCount
// is the number of proto messages in the Apply call (a single call
// can batch several messages — SetCartItems, for example, sends
// ClearCart + N AddItem together); the counter is incremented by
// that count so the dashboard's `rate(cart_mutations_total[1m])`
// reports "messages per second" rather than "calls per second",
// matching the original `grainMutations.Add(len(data.Mutations))`
// semantics. The returned closure is nil-safe — pass a nil *Metrics
// in and the call is a no-op.
func (m *Metrics) MutationStarted(mutationCount int) func(err error) {
if m == nil {
return func(error) {}
}
start := time.Now()
return func(err error) {
if mutationCount > 0 {
m.MutationsTotal.Add(float64(mutationCount))
}
if err != nil {
m.MutationFailuresTotal.Inc()
}
m.MutationLatency.Observe(time.Since(start).Seconds())
}
}
// SetPoolStats updates the three pool-level gauges (grains_in_pool,
// active_grains, pool_usage) in one call. Safe to invoke under the
// pool's localMu — it does not take any lock itself, only prometheus
// atomic counters.
func (m *Metrics) SetPoolStats(grains, poolSize int) {
if m == nil {
return
}
m.GrainsInPool.Set(float64(grains))
m.ActiveGrains.Set(float64(grains))
if poolSize > 0 {
m.PoolUsage.Set(float64(grains) / float64(poolSize))
} else {
m.PoolUsage.Set(0)
}
}
// IncSpawn bumps the grain-spawned counter. Nil-safe.
func (m *Metrics) IncSpawn() {
if m == nil {
return
}
m.GrainSpawnedTotal.Inc()
}
// IncLookup bumps the grain-lookups counter. Nil-safe.
func (m *Metrics) IncLookup() {
if m == nil {
return
}
m.GrainLookupsTotal.Inc()
}
// IncNegotiation bumps the remote-negotiation counter. Nil-safe.
func (m *Metrics) IncNegotiation() {
if m == nil {
return
}
m.RemoteNegotiationTotal.Inc()
}
// SetConnectedRemotes updates the connected-remotes gauge. Nil-safe.
func (m *Metrics) SetConnectedRemotes(n int) {
if m == nil {
return
}
m.ConnectedRemotes.Set(float64(n))
}
+162 -19
View File
@@ -28,6 +28,12 @@ type SimpleGrainPool[V any] struct {
// grain processes a single message at a time (the actor guarantee).
grainLocks *keyedMutex
// metrics is the prometheus instrumentation wired in by NewMetrics
// at composition time. Nil is fine — every touch site nil-checks
// first, so the existing tests (which construct a pool without
// metrics) keep working.
metrics *Metrics
// Cluster coordination --------------------------------------------------
hostname string
remoteMu sync.RWMutex
@@ -39,6 +45,16 @@ type SimpleGrainPool[V any] struct {
purgeTicker *time.Ticker
}
// noLocalStamp is the sentinel value used in the parallel []int64
// lastChanges slice to mark an id for which the broadcaster has no
// local grain (e.g. TakeOwnership called before anyone has loaded the
// grain, or a custom Host implementation that doesn't carry a stamp).
// Receivers treat this sentinel as "no arbitration possible" and fall
// back to the pre-arbitration "always accept remote" verdict so the
// behaviour is identical to running an older binary that didn't carry
// stamps at all.
const noLocalStamp int64 = -1
type GrainPoolConfig[V any] struct {
Hostname string
Spawn func(ctx context.Context, id uint64) (Grain[V], error)
@@ -51,6 +67,13 @@ type GrainPoolConfig[V any] struct {
PoolSize int
MutationRegistry MutationRegistry
Storage LogStorage[V]
// Metrics is the prometheus instrumentation to register pool-level
// counters, gauges, and histograms with. Nil-safe: when unset, all
// touch sites are no-ops. Use actor.NewMetrics("cart") (or the
// matching service prefix) to construct the per-service metrics
// struct, and pair it with DiskStorage.SetMetrics so the same
// Metrics instance covers both pool and event-log metrics.
Metrics *Metrics
}
func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V], error) {
@@ -64,6 +87,7 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
ttl: config.TTL,
poolSize: config.PoolSize,
hostname: config.Hostname,
metrics: config.Metrics,
remoteOwners: make(map[uint64]Host[V]),
remoteHosts: make(map[string]Host[V]),
grainLocks: newKeyedMutex(),
@@ -76,6 +100,11 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
}
}()
// Publish the initial pool stats so the dashboard has a number to
// draw on first scrape, rather than waiting for the first grain
// spawn / eviction to bump the gauges.
p.metrics.SetPoolStats(0, p.poolSize)
return p, nil
}
@@ -95,22 +124,31 @@ func (p *SimpleGrainPool[V]) RemoveListener(listener LogListener) {
func (p *SimpleGrainPool[V]) purge() {
purgeLimit := time.Now().Add(-p.ttl)
purgedIds := make([]uint64, 0, len(p.grains))
purgedStamps := make([]int64, 0, len(p.grains))
p.localMu.Lock()
evicted := 0
for id, grain := range p.grains {
if grain.GetLastAccess().Before(purgeLimit) {
purgedIds = append(purgedIds, id)
if p.destroy != nil {
if err := p.destroy(grain); err != nil {
log.Printf("failed to destroy grain %d: %v", id, err)
}
}
// Capture the lastChange stamp BEFORE delete so peers
// receive an honest per-id eviction record on the wire.
purgedIds = append(purgedIds, id)
purgedStamps = append(purgedStamps, grain.GetLastChange().UnixNano())
delete(p.grains, id)
evicted++
}
}
remaining := len(p.grains)
p.localMu.Unlock()
if evicted > 0 {
p.metrics.SetPoolStats(remaining, p.poolSize)
}
p.forAllHosts(func(remote Host[V]) {
remote.AnnounceExpiry(purgedIds)
remote.AnnounceExpiry(purgedIds, purgedStamps)
})
}
@@ -136,7 +174,14 @@ func (p *SimpleGrainPool[V]) GetLocalIds() []uint64 {
return ids
}
func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error {
func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64, _ []int64) error {
// lastChanges is informational on this path; expiry is unilateral
// from the announcer's perspective — we just drop the id from any
// remoteOwners mapping so future cross-pod forwards stop hitting a
// dead peer. The parallel stamp slice is kept in the signature for
// wire symmetry with AnnounceOwnership and so a future hardening
// pass can use stamps to detect ghost-evictions (e.g. peer evicted
// with a stamp newer than our last seen Apply result).
p.remoteMu.Lock()
defer p.remoteMu.Unlock()
for _, id := range ids {
@@ -145,7 +190,7 @@ func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error
return nil
}
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) error {
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64, lastChanges []int64) error {
p.remoteMu.RLock()
remoteHost, exists := p.remoteHosts[host]
p.remoteMu.RUnlock()
@@ -156,14 +201,75 @@ func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) er
}
remoteHost = createdHost
}
// Lock order: remoteMu before localMu. Unlock in reverse order so
// the localMu gauge read below cannot deadlock against a concurrent
// p.localMu holder.
p.remoteMu.Lock()
defer p.remoteMu.Unlock()
p.localMu.Lock()
defer p.localMu.Unlock()
for _, id := range ids {
log.Printf("Handling ownership change for cart %d to host %s", id, host)
var accepted, kept, legacy int
for i, id := range ids {
// remoteStamp is the announcer's lastChange at broadcast time,
// or noLocalStamp (-1) if the announcer didn't have a local
// grain (legacy / TakeOwnership path). The
// 0..len(lastChanges)-1 slice is indexed parallel to ids; out
// of range falls back to noLocalStamp (= "no arbitration").
var remoteStamp int64 = noLocalStamp
if i < len(lastChanges) {
remoteStamp = lastChanges[i]
}
var localStamp int64 = noLocalStamp
var hasLocal bool
if g, ok := p.grains[id]; ok {
localStamp = g.GetLastChange().UnixNano()
hasLocal = true
}
// First-spawn-wins arbitration. Five distinct cases, ordered:
// 1) remoteStamp == noLocalStamp → announcer didn't carry a
// real stamp (legacy or TakeOwnership). Keep current
// "always accept remote" behaviour so mixed-version
// rollouts and unbroadcast-advertised TakeOwnership don't
// regress.
// 2) localStamp == noLocalStamp → we have no grain at all.
// Accept remote so the next read forwards to the right
// owner instead of us spawning a parallel projection.
// 3) stamps differ → first-spawn-wins. The pod that spawned
// the grain earlier (smaller stamp) owns it; its broadcast
// is authoritative. The newer pod's projection is
// redundant — we drop our local copy and defer to remote
// so future OwnerHost lookups route correctly.
// 4) stamps equal → deterministic tie-break: lexicographically
// smaller hostname wins (both pods compute the same
// verdict, so the cold-cache first-touch cannot flip-flop).
var keepLocal bool
switch {
case remoteStamp == noLocalStamp:
keepLocal = false
if hasLocal {
legacy++
}
case localStamp == noLocalStamp:
keepLocal = false
case localStamp != remoteStamp:
keepLocal = localStamp < remoteStamp
default:
keepLocal = p.hostname < host
}
if keepLocal {
kept++
continue
}
delete(p.grains, id)
p.remoteOwners[id] = remoteHost
accepted++
}
remaining := len(p.grains)
p.localMu.Unlock()
p.remoteMu.Unlock()
p.metrics.SetPoolStats(remaining, p.poolSize)
if kept > 0 || accepted > 0 || legacy > 0 {
log.Printf("HandleOwnershipChange from %s: accepted_remote=%d kept_local=%d legacy_no_stamp=%d",
host, accepted, kept, legacy)
}
return nil
}
@@ -209,8 +315,9 @@ func (p *SimpleGrainPool[V]) AddRemote(host string) (Host[V], error) {
return existing, nil
}
p.remoteHosts[host] = remote
count := len(p.remoteHosts)
p.remoteMu.Unlock()
// connectedRemotes.Set(float64(p.RemoteCount()))
p.metrics.SetConnectedRemotes(count)
log.Printf("Connected to remote host %s", host)
go p.pingLoop(remote)
@@ -250,6 +357,7 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
delete(p.remoteOwners, id)
}
}
remoteCount := len(p.remoteHosts)
log.Printf("Removing host %s, grains: %d", host, count)
p.remoteMu.Unlock()
@@ -257,7 +365,7 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
if exists {
remote.Close()
}
// connectedRemotes.Set(float64(p.RemoteCount()))
p.metrics.SetConnectedRemotes(remoteCount)
}
func (p *SimpleGrainPool[V]) RemoteCount() int {
@@ -332,7 +440,7 @@ func (p *SimpleGrainPool[V]) Negotiate(otherHosts []string) {
}
func (p *SimpleGrainPool[V]) SendNegotiation() {
//negotiationCount.Inc()
p.metrics.IncNegotiation()
p.remoteMu.RLock()
hosts := make([]string, 0, len(p.remoteHosts)+1)
@@ -366,10 +474,9 @@ func (p *SimpleGrainPool[V]) forAllHosts(fn func(Host[V])) {
wg := sync.WaitGroup{}
for _, host := range rh {
wg.Go(func() { fn(host) })
}
wg.Wait()
for name, host := range rh {
if !host.IsHealthy() {
@@ -387,8 +494,27 @@ func (p *SimpleGrainPool[V]) broadcastOwnership(ids []uint64) {
return
}
// Capture each id's grain.lastChange UnixNano stamp under the local
// read-lock so peers receive an honest oracle for the
// first-spawn-wins arbitration. For ids that don't have a local
// grain (e.g. TakeOwnership called before anyone has loaded the
// grain, or a caller pushing ids into the pool from outside the
// spawn path), substitute the noLocalStamp sentinel — receivers see
// -1 and fall back to "always accept remote", preserving the
// pre-arbitration semantics for that case.
p.localMu.RLock()
stamps := make([]int64, 0, len(ids))
for _, id := range ids {
if g, ok := p.grains[id]; ok {
stamps = append(stamps, g.GetLastChange().UnixNano())
} else {
stamps = append(stamps, noLocalStamp)
}
}
p.localMu.RUnlock()
p.forAllHosts(func(rh Host[V]) {
rh.AnnounceOwnership(p.hostname, ids)
rh.AnnounceOwnership(p.hostname, ids, stamps)
})
log.Printf("%s taking ownership of %d ids", p.hostname, len(ids))
// go p.statsUpdate()
@@ -406,10 +532,13 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr
if err != nil {
return nil, err
}
p.metrics.IncSpawn()
go p.broadcastOwnership([]uint64{id})
p.localMu.Lock()
p.grains[id] = grain
remaining := len(p.grains)
p.localMu.Unlock()
p.metrics.SetPoolStats(remaining, p.poolSize)
return grain, nil
}
@@ -418,7 +547,20 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr
// var ErrNotOwner = fmt.Errorf("not owner")
// Apply applies a mutation to a grain.
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error) {
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (result *MutationResult[V], err error) {
// The metric closure fires exactly once on every return path: the
// latency / counters cover the full Apply (lock acquire + spawn +
// handler + storage enqueue), and the failure counter only bumps
// when the call returned a non-nil error. `len(mutation)` is the
// number of proto messages in this Apply call — a single call can
// batch several messages (SetCartItems sends ClearCart + N AddItem
// together), and the counter is incremented by that count so the
// dashboard's `rate(cart_mutations_total[1m])` reports messages
// per second rather than calls per second, matching the original
// `grainMutations.Add(len(data.Mutations))` semantics.
done := p.metrics.MutationStarted(len(mutation))
defer func() { done(err) }()
// Serialize all access to this grain: spawn, mutation handlers and the
// final state read happen atomically with respect to other callers of the
// same id. Different ids never contend.
@@ -444,19 +586,20 @@ func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...p
for _, listener := range p.listeners {
go listener.AppendMutations(id, mutations...)
}
result, err := grain.GetCurrentState()
state, err := grain.GetCurrentState()
if err != nil {
return nil, err
}
return &MutationResult[V]{
Result: *result,
Result: *state,
Mutations: mutations,
}, nil
}
// Get returns the current state of a grain.
func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (*V, error) {
func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (result *V, err error) {
p.metrics.IncLookup()
unlock := p.grainLocks.lock(id)
defer unlock()
+434
View File
@@ -0,0 +1,434 @@
package actor
import (
"context"
"fmt"
"io"
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
"google.golang.org/protobuf/proto"
)
type testState struct {
Value int `json:"value"`
}
type testGrain struct {
id uint64
accessed time.Time
}
func (g *testGrain) GetId() uint64 { return g.id }
func (g *testGrain) GetLastAccess() time.Time { return g.accessed }
func (g *testGrain) GetLastChange() time.Time { return g.accessed }
func (g *testGrain) GetCurrentState() (*testState, error) {
return &testState{Value: int(g.id)}, nil
}
type mockHost struct {
name string
healthy bool
closed atomic.Bool
closeCalls atomic.Int32
actorIDs []uint64
negotiateFn func([]string) ([]string, error)
mu sync.Mutex
ownership []uint64
expiry []uint64
}
func newMockHost(name string) *mockHost {
return &mockHost{name: name, healthy: true}
}
func (m *mockHost) AnnounceExpiry(ids []uint64, _ []int64) {
m.mu.Lock()
m.expiry = append(m.expiry, ids...)
m.mu.Unlock()
}
func (m *mockHost) Negotiate(otherHosts []string) ([]string, error) {
if m.negotiateFn != nil {
return m.negotiateFn(otherHosts)
}
return otherHosts, nil
}
func (m *mockHost) Name() string { return m.name }
func (m *mockHost) Proxy(_ uint64, _ http.ResponseWriter, _ *http.Request, _ io.Reader) (bool, error) {
return false, fmt.Errorf("not implemented")
}
func (m *mockHost) Apply(_ context.Context, id uint64, _ ...proto.Message) (*MutationResult[testState], error) {
return &MutationResult[testState]{Result: testState{Value: int(id)}}, nil
}
func (m *mockHost) Get(_ context.Context, id uint64) (*testState, error) {
return &testState{Value: int(id)}, nil
}
func (m *mockHost) GetActorIds() []uint64 { return m.actorIDs }
func (m *mockHost) Close() error {
m.closeCalls.Add(1)
m.closed.Store(true)
return nil
}
func (m *mockHost) Ping() bool { return m.healthy }
func (m *mockHost) IsHealthy() bool { return m.healthy }
func (m *mockHost) AnnounceOwnership(_ string, ids []uint64, _ []int64) {
m.mu.Lock()
m.ownership = append(m.ownership, ids...)
m.mu.Unlock()
}
func newTestPool(t *testing.T, spawnHost func(string) (Host[testState], error)) *SimpleGrainPool[testState] {
t.Helper()
reg := NewMutationRegistry()
pool, err := NewSimpleGrainPool(GrainPoolConfig[testState]{
Hostname: "local",
Spawn: func(_ context.Context, id uint64) (Grain[testState], error) {
return &testGrain{id: id, accessed: time.Now()}, nil
},
SpawnHost: spawnHost,
TTL: time.Hour,
PoolSize: 100,
MutationRegistry: reg,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
return pool
}
func TestSimpleGrainPoolGetAndApply(t *testing.T) {
pool := newTestPool(t, func(string) (Host[testState], error) {
return nil, fmt.Errorf("no remotes")
})
got, err := pool.Get(context.Background(), 42)
if err != nil {
t.Fatal(err)
}
if got.Value != 42 {
t.Fatalf("Get value = %d, want 42", got.Value)
}
ids := pool.GetLocalIds()
if len(ids) != 1 || ids[0] != 42 {
t.Fatalf("GetLocalIds = %v, want [42]", ids)
}
}
func TestSimpleGrainPoolAddRemoteDedup(t *testing.T) {
var spawnCalls atomic.Int32
host := newMockHost("peer-a")
pool := newTestPool(t, func(name string) (Host[testState], error) {
spawnCalls.Add(1)
if name != host.name {
t.Fatalf("spawn host %q, want %q", name, host.name)
}
return host, nil
})
const workers = 8
var wg sync.WaitGroup
wg.Add(workers)
for range workers {
go func() {
defer wg.Done()
if _, err := pool.AddRemote(host.name); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
if pool.RemoteCount() != 1 {
t.Fatalf("RemoteCount = %d, want 1", pool.RemoteCount())
}
if spawnCalls.Load() != 1 {
t.Fatalf("spawn calls = %d, want 1", spawnCalls.Load())
}
if host.closeCalls.Load() != 0 {
t.Fatalf("duplicate remote closed = %d, want 0", host.closeCalls.Load())
}
}
func TestSimpleGrainPoolAddRemoteRaceClosesDuplicate(t *testing.T) {
start := make(chan struct{})
var spawnCalls atomic.Int32
var spawned []*mockHost
pool := newTestPool(t, func(name string) (Host[testState], error) {
spawnCalls.Add(1)
h := newMockHost(name)
spawned = append(spawned, h)
if spawnCalls.Load() == 1 {
<-start
}
return h, nil
})
go func() {
if _, err := pool.AddRemote("peer-b"); err != nil {
t.Error(err)
}
}()
time.Sleep(20 * time.Millisecond)
close(start)
if _, err := pool.AddRemote("peer-b"); err != nil {
t.Fatal(err)
}
if pool.RemoteCount() != 1 {
t.Fatalf("RemoteCount = %d, want 1", pool.RemoteCount())
}
if spawnCalls.Load() != 2 {
t.Fatalf("spawn calls = %d, want 2", spawnCalls.Load())
}
closed := 0
deadline := time.After(2 * time.Second)
for closed < 1 {
closed = 0
for _, h := range spawned {
if h.closeCalls.Load() > 0 {
closed++
}
}
if closed >= 1 {
break
}
select {
case <-deadline:
t.Fatal("timed out waiting for duplicate remote host Close")
default:
time.Sleep(10 * time.Millisecond)
}
}
}
func TestSimpleGrainPoolRemoveHost(t *testing.T) {
host := newMockHost("peer-c")
pool := newTestPool(t, func(string) (Host[testState], error) { return host, nil })
if _, err := pool.AddRemote(host.name); err != nil {
t.Fatal(err)
}
pool.remoteMu.Lock()
pool.remoteOwners[99] = host
pool.remoteMu.Unlock()
pool.RemoveHost(host.name)
if pool.RemoteCount() != 0 {
t.Fatalf("RemoteCount = %d, want 0", pool.RemoteCount())
}
if owner, ok := pool.OwnerHost(99); ok || owner != nil {
t.Fatalf("OwnerHost still mapped: %#v ok=%v", owner, ok)
}
if host.closeCalls.Load() != 1 {
t.Fatalf("Close calls = %d, want 1", host.closeCalls.Load())
}
}
func TestSimpleGrainPoolHandleOwnershipChange(t *testing.T) {
host := newMockHost("peer-d")
pool := newTestPool(t, func(string) (Host[testState], error) { return host, nil })
// First-spawn-wins arbitration: a remote broadcast wins only when its
// lastChange stamp is strictly earlier than the local grain's stamp
// (or the local grain is absent). Capture the local spawn time once
// and pass a stamp 1ns earlier to the remote so the test continues to
// assert "external owner takes over given an earlier remote spawn"
// under the new arbitration rule.
spawnTime := time.Now()
pool.localMu.Lock()
pool.grains[7] = &testGrain{id: 7, accessed: spawnTime}
pool.localMu.Unlock()
remoteStamp := spawnTime.UnixNano() - 1
if err := pool.HandleOwnershipChange(host.name, []uint64{7}, []int64{remoteStamp}); err != nil {
t.Fatal(err)
}
pool.localMu.RLock()
_, local := pool.grains[7]
pool.localMu.RUnlock()
if local {
t.Fatal("local grain should be removed after ownership change")
}
owner, ok := pool.OwnerHost(7)
if !ok || owner.Name() != host.name {
t.Fatalf("OwnerHost = (%v, %v), want (%q, true)", owner, ok, host.name)
}
}
// TestSimpleGrainPoolHandleOwnershipChangeArbitration verifies the
// first-spawn-wins arbitration rules for HandleOwnershipChange:
//
// - Remote with earlier lastChange than local → accept remote.
// - Remote with later lastChange than local → keep local.
// - Equal lastChange → lower hostname wins.
// - Remote stamp == -1 (legacy path) → accept remote (pre-arbitration).
// - Local grain absent → accept remote.
func TestSimpleGrainPoolHandleOwnershipChangeArbitration(t *testing.T) {
host := newMockHost("peer-arbitrate")
// Each table case constructs a fresh pool inside t.Run — we don't
// share state across cases because per-case hostname differs
// (hostname is the tie-break oracle on equal lastChange stamps).
now := time.Now().UnixNano()
cases := []struct {
name string
name_local string // pool hostname for ordering check
id uint64
localStamp int64 // UnixNano of the local grain; -1 = no local grain
remoteStamp int64 // UnixNano stamped on the broadcast
expectLocal bool // true → local grain still resident
expectRemote bool // true → remoteOwners[id] = host
}{
{
name: "remote older stamp wins",
name_local: "zzz", // pool above "peer-arbitrate" lex
id: 10,
localStamp: now + 100,
remoteStamp: now,
expectLocal: false,
expectRemote: true,
},
{
name: "local earlier stamp keeps",
name_local: "aaa", // pool below "peer-arbitrate" lex but stamp decides
id: 11,
localStamp: now,
remoteStamp: now + 100,
expectLocal: true,
expectRemote: false,
},
{
name: "equal stamp + lower local hostname keeps",
name_local: "a-peer", // below host
id: 12,
localStamp: now,
remoteStamp: now,
expectLocal: true,
expectRemote: false,
},
{
name: "equal stamp + higher local hostname defers",
name_local: "zzz-peer", // above host
id: 13,
localStamp: now,
remoteStamp: now,
expectLocal: false,
expectRemote: true,
},
{
name: "legacy (no remote stamp) accepts remote",
name_local: "zzz", // we'd otherwise keep local
id: 14,
localStamp: now + 100,
remoteStamp: -1,
expectLocal: false,
expectRemote: true,
},
{
name: "no local grain accepts remote",
name_local: "aaa",
id: 15,
localStamp: -1,
remoteStamp: now,
expectLocal: false,
expectRemote: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Fresh pool per case so hostname differences don't leak.
p := newTestPool(t, func(string) (Host[testState], error) { return host, nil })
p.hostname = tc.name_local
p.localMu.Lock()
if tc.localStamp != -1 {
p.grains[tc.id] = &testGrain{id: tc.id, accessed: time.Unix(0, tc.localStamp)}
}
p.localMu.Unlock()
if err := p.HandleOwnershipChange(host.name, []uint64{tc.id}, []int64{tc.remoteStamp}); err != nil {
t.Fatal(err)
}
p.localMu.RLock()
_, hasLocal := p.grains[tc.id]
p.localMu.RUnlock()
if hasLocal != tc.expectLocal {
t.Fatalf("local grain presence = %v, want %v", hasLocal, tc.expectLocal)
}
owner, ok := p.OwnerHost(tc.id)
if ok != tc.expectRemote {
t.Fatalf("OwnerHost ok = %v, want %v", ok, tc.expectRemote)
}
if tc.expectRemote && (owner == nil || owner.Name() != host.name) {
t.Fatalf("Owner = %v, want host %q", owner, host.name)
}
})
}
}
func TestSimpleGrainPoolPurgeEvictsStaleGrains(t *testing.T) {
pool := newTestPool(t, func(string) (Host[testState], error) {
return nil, fmt.Errorf("no remotes")
})
pool.ttl = time.Minute
stale := &testGrain{id: 1, accessed: time.Now().Add(-2 * time.Minute)}
fresh := &testGrain{id: 2, accessed: time.Now()}
pool.localMu.Lock()
pool.grains[1] = stale
pool.grains[2] = fresh
pool.localMu.Unlock()
pool.purge()
pool.localMu.RLock()
defer pool.localMu.RUnlock()
if _, ok := pool.grains[1]; ok {
t.Fatal("stale grain should be purged")
}
if _, ok := pool.grains[2]; !ok {
t.Fatal("fresh grain should remain")
}
}
func TestSimpleGrainPoolBroadcastOwnership(t *testing.T) {
host := newMockHost("peer-e")
pool := newTestPool(t, func(string) (Host[testState], error) { return host, nil })
if _, err := pool.AddRemote(host.name); err != nil {
t.Fatal(err)
}
pool.broadcastOwnership([]uint64{11, 12})
host.mu.Lock()
defer host.mu.Unlock()
if len(host.ownership) != 2 {
t.Fatalf("ownership announces = %v, want 2 ids", host.ownership)
}
}
+29 -7
View File
@@ -12,6 +12,12 @@ import (
type StateStorage struct {
registry MutationRegistry
// metrics is set by the owning DiskStorage via SetMetrics. Nil is fine —
// every method that touches it nil-checks first. The pointer is
// intentionally unexported: callers set it through DiskStorage.SetMetrics
// so the embedded StateStorage always tracks the parent disk storage's
// metrics, even if the embedded value is replaced.
metrics *Metrics
}
type StorageEvent struct {
@@ -50,10 +56,20 @@ func (s *StateStorage) Load(r io.Reader, onMessage func(msg proto.Message, timeS
return err
}
func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp time.Time) error {
// Append serialises a mutation as a StorageEvent and writes it (plus a
// trailing newline) to io. The first return value is the number of
// bytes written, which the caller (DiskStorage) adds to the
// event_log_bytes_written_total counter; the second is the underlying
// write error, if any. ErrUnknownType is returned (with 0 bytes) when
// the mutation's type is not registered, and is also counted in
// event_log_unknown_types_total.
func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp time.Time) (int, error) {
typeName, ok := s.registry.GetTypeName(mutation)
if !ok {
return ErrUnknownType
if s.metrics != nil {
s.metrics.EventLogUnknownTypes.Inc()
}
return 0, ErrUnknownType
}
event := &StorageEvent{
Type: typeName,
@@ -62,13 +78,16 @@ func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp ti
}
jsonBytes, err := json.Marshal(event)
if err != nil {
return err
return 0, err
}
if _, err := io.Write(jsonBytes); err != nil {
return err
n, err := io.Write(jsonBytes)
total := n
if err != nil {
return total, err
}
io.Write([]byte("\n"))
return nil
n, err = io.Write([]byte("\n"))
total += n
return total, err
}
func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
@@ -83,6 +102,9 @@ func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
typeName := event.Type
mutation, ok := s.registry.Create(typeName)
if !ok {
if s.metrics != nil {
s.metrics.EventLogUnknownTypes.Inc()
}
return nil, ErrUnknownType
}
if err := json.Unmarshal(event.Mutation, mutation); err != nil {
+83 -51
View File
@@ -12,20 +12,19 @@ package backofficeadmin
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"strings"
"time"
actor "git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
"git.k6n.net/mats/platform/rabbit"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/redis/go-redis/v9"
)
// CartFileInfo is the metadata returned by the cart listing endpoint.
@@ -52,9 +51,8 @@ type Config struct {
CheckoutDataDir string
// ProfileDataDir holds the profile event logs (optional, default "" — customer endpoints disabled).
ProfileDataDir string
// RedisAddress / RedisPassword reach the inventory store.
RedisAddress string
RedisPassword string
// PromotionsFile holds the path to promotions.json (optional).
PromotionsFile string
}
// App is the commerce admin control plane. Construct with New, register its
@@ -62,15 +60,16 @@ type Config struct {
type App struct {
fs *FileServer
hub *Hub
rdb *redis.Client
inv *inventory.RedisInventoryService
cs *CustomerServer
OnVouchersChange func(codes []string)
}
// New constructs the commerce admin: it opens the Redis inventory service, the
// cart/checkout disk event-log stores, the file server, and the WebSocket hub.
// It does not register routes (RegisterRoutes), start background work (Start),
// or open a listener.
// New constructs the commerce admin: it opens the cart/checkout disk event-log
// stores, the file server, and the WebSocket hub. It does not register routes
// (RegisterRoutes), start background work (Start), or open a listener.
//
// Inventory writes no longer live here — they go through the standalone inventory
// service's HTTP API (/api/inventory/batch), reverse-proxied by the host.
func New(cfg Config) (*App, error) {
if cfg.DataDir == "" {
cfg.DataDir = "data"
@@ -79,18 +78,74 @@ func New(cfg Config) (*App, error) {
cfg.CheckoutDataDir = "checkout-data"
}
rdb := redis.NewClient(&redis.Options{
Addr: cfg.RedisAddress,
Password: cfg.RedisPassword,
DB: 0,
})
inventoryService, err := inventory.NewRedisInventoryService(rdb)
if err != nil {
return nil, err
}
_ = os.MkdirAll(cfg.DataDir, 0755)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
promotionsPath := cfg.PromotionsFile
if promotionsPath == "" {
promotionsPath = os.Getenv("PROMOTIONS_FILE")
}
if promotionsPath == "" {
promotionsPath = "data/promotions.json"
}
if promotionStore, err := promotions.NewStore(promotionsPath); err == nil {
promotionService := promotions.NewPromotionService(nil)
reg.RegisterProcessor(
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
for _, v := range g.Vouchers {
if v != nil {
v.BypassedByPromotions = false
}
}
g.UpdateTotals()
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
hasBypassed := false
for _, res := range results {
if res.Applicable {
promotions.WalkConditions(res.Rule.Conditions, func(c promotions.Condition) bool {
if bc, ok := c.(promotions.BaseCondition); ok && bc.Type == promotions.CondCouponCode {
var codes []string
if s, ok := bc.Value.AsString(); ok {
codes = append(codes, strings.ToLower(s))
} else if arr, ok := bc.Value.AsStringSlice(); ok {
for _, s := range arr {
codes = append(codes, strings.ToLower(s))
}
}
for _, code := range codes {
for _, v := range g.Vouchers {
if v != nil && strings.ToLower(v.Code) == code {
if !v.BypassedByPromotions {
v.BypassedByPromotions = true
hasBypassed = true
}
}
}
}
}
return true
})
}
}
if hasBypassed {
g.UpdateTotals()
promotionCtx = promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
results, _ = promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
}
promotionService.ApplyResults(g, results, promotionCtx)
g.Version++
return nil
}),
)
} else {
log.Printf("backofficeadmin: unable to load promotions store from %s: %v", promotionsPath, err)
}
diskStorage := actor.NewDiskStorage[cart.CartGrain](cfg.DataDir, reg)
_ = os.MkdirAll(cfg.CheckoutDataDir, 0755)
@@ -107,10 +162,8 @@ func New(cfg Config) (*App, error) {
}
return &App{
fs: NewFileServer(cfg.DataDir, cfg.CheckoutDataDir, diskStorage, diskStorageCheckout),
fs: NewFileServer(cfg.DataDir, cfg.CheckoutDataDir, promotionsPath, diskStorage, diskStorageCheckout),
hub: NewHub(),
rdb: rdb,
inv: inventoryService,
cs: customerSrv,
}, nil
}
@@ -119,11 +172,11 @@ func New(cfg Config) (*App, error) {
// apply auth or CORS — the composing host (or standalone main) wraps the mux
// with those.
func (a *App) RegisterRoutes(mux *http.ServeMux) {
a.fs.OnVouchersChange = a.OnVouchersChange
mux.HandleFunc("GET /carts", a.fs.CartsHandler)
mux.HandleFunc("GET /cart/{id}", a.fs.CartHandler)
mux.HandleFunc("GET /checkouts", a.fs.CheckoutsHandler)
mux.HandleFunc("GET /checkout/{id}", a.fs.CheckoutHandler)
mux.HandleFunc("PUT /inventory/{locationId}/{sku}", a.updateInventory)
mux.HandleFunc("/promotions", a.fs.PromotionsHandler)
mux.HandleFunc("/vouchers", a.fs.VoucherHandler)
mux.HandleFunc("/promotion/{id}", a.fs.PromotionPartHandler)
@@ -134,28 +187,7 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
}
}
func (a *App) updateInventory(w http.ResponseWriter, r *http.Request) {
locationID := inventory.LocationID(r.PathValue("locationId"))
sku := inventory.SKU(r.PathValue("sku"))
pipe := a.rdb.Pipeline()
var payload struct {
Quantity int64 `json:"quantity"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
a.inv.UpdateInventory(r.Context(), pipe, sku, locationID, payload.Quantity)
if _, err := pipe.Exec(r.Context()); err != nil {
http.Error(w, "failed to update inventory", http.StatusInternalServerError)
return
}
if err := a.inv.SendInventoryChanged(r.Context(), sku, locationID); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
// Start runs the WebSocket hub loop and, when conn is non-nil, the RabbitMQ
// mutation consumer that broadcasts cart mutations to connected clients. The
@@ -169,10 +201,10 @@ func (a *App) Start(ctx context.Context, conn *rabbit.Conn) error {
return nil
}
// Shutdown releases the App's resources (the Redis client). The background
// goroutines are stopped by cancelling the ctx passed to Start.
// Shutdown releases the App's resources. The background goroutines are stopped
// by cancelling the ctx passed to Start.
func (a *App) Shutdown(_ context.Context) error {
return a.rdb.Close()
return nil
}
// startMutationConsumer subscribes to the cart "mutation" topic and forwards
+34 -6
View File
@@ -26,14 +26,17 @@ type FileServer struct {
// Define fields here
dataDir string
checkoutDataDir string
promotionsFile string
storage actor.LogStorage[cart.CartGrain]
checkoutStorage actor.LogStorage[checkout.CheckoutGrain]
OnVouchersChange func(codes []string)
}
func NewFileServer(dataDir string, checkoutDataDir string, storage actor.LogStorage[cart.CartGrain], checkoutStorage actor.LogStorage[checkout.CheckoutGrain]) *FileServer {
func NewFileServer(dataDir string, checkoutDataDir string, promotionsFile string, storage actor.LogStorage[cart.CartGrain], checkoutStorage actor.LogStorage[checkout.CheckoutGrain]) *FileServer {
return &FileServer{
dataDir: dataDir,
checkoutDataDir: checkoutDataDir,
promotionsFile: promotionsFile,
storage: storage,
checkoutStorage: checkoutStorage,
}
@@ -215,7 +218,10 @@ func (fs *FileServer) CheckoutsHandler(w http.ResponseWriter, r *http.Request) {
}
func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) {
fileName := filepath.Join(fs.dataDir, "promotions.json")
fileName := fs.promotionsFile
if fileName == "" {
fileName = filepath.Join(fs.dataDir, "promotions.json")
}
if r.Method == http.MethodGet {
file, err := os.Open(fileName)
if err != nil {
@@ -271,15 +277,37 @@ func (fs *FileServer) VoucherHandler(w http.ResponseWriter, r *http.Request) {
return
}
if r.Method == http.MethodPost {
_ = os.MkdirAll(filepath.Dir(fileName), 0755)
file, err := os.Create(fileName)
body, err := io.ReadAll(r.Body)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
defer file.Close()
io.Copy(file, r.Body)
var parsed struct {
State struct {
Vouchers []struct {
Code string `json:"code"`
} `json:"vouchers"`
} `json:"state"`
}
var codes []string
if err := json.Unmarshal(body, &parsed); err == nil {
for _, v := range parsed.State.Vouchers {
if v.Code != "" {
codes = append(codes, v.Code)
}
}
}
_ = os.MkdirAll(filepath.Dir(fileName), 0755)
if err := os.WriteFile(fileName, body, 0644); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if fs.OnVouchersChange != nil {
fs.OnVouchersChange(codes)
}
return
}
+97
View File
@@ -0,0 +1,97 @@
# `pkg/cart` — mutation inventory
Every mutation file under `pkg/cart/mutation_*.go` plus the slice of cart
state it touches. The grouping is **subjective** ("Subscription lifecycle",
"Discount surface", etc.) — used as a starting point for collapsing the
file-per-mutation pattern flagged in `agents.md`. Move two related
mutations into one file first; if the test surface stays smaller than the
two-file version, the grouping is real.
| File | Concern | State it READS | State it WRITES |
| --------------------------------------------------- | -------------------- | ----------------------------- | ---------------------------------------- |
| `mutation_items.go` (`mutation_add_item.go` and `mutation_remove_item.go` merged; tests stay in `mutation_add_item_test.go` + `mutation_remove_item_test.go`) | Line item add/remove | `state.Items` | `state.Items`, `state.UpdatedAt` |
| `mutation_change_quantity.go` | Line item quantity | `state.Items[idx]` | `state.Items[idx]`, `state.UpdatedAt` |
| `mutation_clear_cart.go` | Wholesale clear | — | `state.Items`, `state.Vouchers`, `state.UpdatedAt` |
| `mutation_set_user_id.go` | Ownership | — | `state.UserID`, `state.UpdatedAt` |
| `mutation_set_cart_type.go` (+ test) | Cart type | — | `state.Type`, `state.UpdatedAt` |
| `mutation_add_voucher.go` | Discount surface | `state.Vouchers` | `state.Vouchers`, `state.LineTotals` (re-eval), `state.UpdatedAt` |
| `mutation_set_custom_fields.go` (+ tests) | Custom K/V | `state.CustomFields` | `state.CustomFields`, `state.UpdatedAt` |
| `mutation_set_recovery_contact.go` (+ tests) | Recovery | — | `state.Recovery`, `state.UpdatedAt` |
| `mutation_subscription_added.go` | Subscription | `state.Items` | `state.Subscriptions`, `state.UpdatedAt` |
| `mutation_upsert_subscriptiondetails.go` | Subscription details | `state.Subscriptions` | `state.Subscriptions`, `state.UpdatedAt` |
| `mutation_markings.go` (`mutation_line_item_marking.go` and `mutation_remove_line_item_marking.go` merged) | Markings apply/remove | `state.Items[idx].Markings` | `state.Items[idx].Markings`, `state.UpdatedAt` |
## Delete-test quick check
For any proposed grouping, ask: does deleting the merged file concentrate
complexity, or just move lines? For markings — yes, the shared logic
(cascade to pricing) makes the merge worthwhile, and the merge is now done
(see below). For `voucher` vs `custom_fields` — no, those read different
state and have different error contracts; keep separate.
### What is already merged
`pkg/cart/mutation_items.go` (added 2025-07) — AddItem and RemoveItem
both touch `state.Items` and share the `ErrPaymentInProgress`,
`decodeExtra`, and `getOrgPrice` helpers. Merge passed the delete-test
checks: the receiver `*CartMutationContext`, same grain (`*CartGrain`),
same mutation-registry dispatch. Both `mutation_*_test.go` files remain
because they assert specific method behaviour, not generic package
behaviour. Verified: `go test -count=1 ./pkg/cart/...` clean (voucher test
which references `ErrPaymentInProgress` still green).
#### Table corrections when merging
When the two source rows collapsed into one, two corrections landed in
the merge:
- AddItem's old READS column listed `state.Vouchers (for stacking)`.
That clause was **incorrect** — voucher stacking is `AddVoucher`'s
concern, not AddItem's. The merge drops the clause because reading
`mutation_add_item.go` confirms AddItem only reads `state.Items`.
This is a documentation correction, not a behaviour change.
- Both `State it READS / WRITES` columns originally had `(both)`
annotations to remind that two distinct mutations were sharing the
row. After collapse the row already says `Line item add/remove`, so
the `(both)` markers are noise — dropped.
If a future merge lands, follow the same pattern: read the actual source
to verify the row's claimed state surface, and prune label-noise before
compressing two rows into one.
`pkg/cart/mutation_markings.go` (added 2025-07) — LineItemMarking and
RemoveLineItemMarking both touch `state.Items[idx].Markings` and share
the same item-lookup loop (find by ID, mutate `Marking` field). Merge
passed the delete-test: the shared write surface and identical error
contract ("item with ID %d not found") make the grouping real. No test
files existed for either mutation, so the test surface is unchanged.
Verified: `go build ./pkg/cart/...` and `go test -count=1 ./pkg/cart/...`
clean.
### NOT to merge next
`mutation_change_quantity.go` looks like an obvious candidate for the next
merge (same receiver, same grain, same `state.Items` write surface). It
is **not** — quantity has a partial-line-drop arithmetic contract that
add/remove do not:
- `AddItem` always merges or appends; never sets quantity down.
- `RemoveItem` always removes whole lines; never decrements quantity.
- `ChangeQuantity` may drop a line entirely if quantity reaches 0.
If you merge `ChangeQuantity` first, doing the same delete-test check
yields: deleting the merged file leaves the partial-line arithmetic
logic stranded in `Apply`, which IS a real concentration (good). But
the inverse: if `AddItem` later grows a "decrement" edge case, the
merged file will balloon and a re-split becomes expensive. Either
keep `ChangeQuantity` separate, OR merge all three with the merge
scoped to a "line item lifecycle" package and the partial-line
arithmetic under a clearly-named helper.
## How this maps onto a refactor
1. Pick a row with shared logic (markings, subscription details).
2. Move related mutations + their tests into one file under
`pkg/cart/<concern>.go`.
3. Re-run `go test ./pkg/cart/...` — same assertions, smaller surface.
4. Update this table to reflect the new file layout.
+47 -1
View File
@@ -6,6 +6,7 @@ import (
"time"
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
)
@@ -85,6 +86,25 @@ type Notice struct {
Code *string `json:"code,omitempty"`
}
// PushToken is the Go-level mirror of cart_messages.PushToken: a generic,
// provider-agnostic push-delivery target. Stored on the grain so the
// abandoned-cart recovery scanner can hand it to a notifier without
// re-decoding proto messages; the wire shape lives in proto/cart.proto.
//
// SECURITY: the raw Token persists verbatim to the per-pod event log
// (CartGrain JSON-marshals PushTokens on every mutation). Anyone with read
// access to the cart service's data dir — PVC snapshots, debug dumps, log
// archival — can extract device handles. A future hardening pass should
// either hash-on-write (hash.compareAtLaunch) for stateless matching or
// encrypt-at-rest with a key the notifier owns. v0 keeps the seam: a real
// notifier should treat the stored token as opaque, fetch any canonical
// canonicalised handle from the input side (frontend/web SDK), and use
// what's here only for routing.
type PushToken struct {
Platform string `json:"platform"`
Token string `json:"token"`
}
type CartPaymentStatus string
const (
@@ -119,6 +139,7 @@ type AppliedPromotion struct {
Type string `json:"type,omitempty"`
Label string `json:"label,omitempty"`
Discount *Price `json:"discount,omitempty"`
ItemIds []uint32 `json:"itemIds,omitempty"`
Pending bool `json:"pending,omitempty"`
Progress map[string]interface{} `json:"progress,omitempty"`
@@ -130,15 +151,28 @@ type CartGrain struct {
lastVoucherId uint32
lastAccess time.Time
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
userId string
UserId string `json:"userId,omitempty"`
Currency string `json:"currency"`
Language string `json:"language"`
Version uint `json:"version"`
InventoryReserved bool `json:"inventoryReserved"`
Type cart_messages.CartType `json:"type,omitempty"`
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
TotalPrice *Price `json:"totalPrice"`
TotalDiscount *Price `json:"totalDiscount"`
// EvaluatedItems is the per-line promotion breakdown — see
// EvaluatedItem for shape and semantics. Populated by the canonical
// promotion pipeline (pkg/promotions.PromotionService.EvaluateAndApply)
// after every successful mutation, alongside TotalDiscount and
// AppliedPromotions, so any consumer of the grain (UCP cart response,
// legacy /cart HTTP handler, cart-mcp, AMQP mutation feed) sees the
// per-line discount/effective totals without re-running the engine at
// response time. Mirrors the same shape /promotions/evaluate-with-cart
// has returned since the breakdown feature was introduced; deliberately
// persisted via the event-log JSON block so we don't have to re-derive
// it on every read path (same precedent as TotalPrice/TotalDiscount).
EvaluatedItems []EvaluatedItem `json:"evaluatedItems,omitempty"`
Processing bool `json:"processing"`
//PaymentInProgress uint16 `json:"paymentInProgress"`
OrderReference string `json:"orderReference,omitempty"`
@@ -148,6 +182,14 @@ type CartGrain struct {
Notifications []CartNotification `json:"cartNotification,omitempty"`
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
// Email is the cart's recovery contact address. Set explicitly via the
// SetRecoveryContact mutation (independent of the linked profile's email),
// so abandoned-cart recovery can fire before login. Empty string = no email.
Email string `json:"email,omitempty"`
// PushTokens holds every push delivery target the shopper attached to this
// cart. Empty list = no push. PUT-style replaced by SetRecoveryContact.
PushTokens []PushToken `json:"pushTokens,omitempty"`
//CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
CheckoutStatus *CartPaymentStatus `json:"checkoutStatus,omitempty"`
//CheckoutCountry string `json:"checkoutCountry,omitempty"`
@@ -297,6 +339,10 @@ func (c *CartGrain) UpdateTotals() {
}
if c.Type == cart_messages.CartType_WISHLIST || c.Type == cart_messages.CartType_OFFER {
return
}
for _, voucher := range c.Vouchers {
_, ok := voucher.AppliesTo(c)
voucher.Applied = false
+104 -6
View File
@@ -2,17 +2,19 @@ package cart
import (
"context"
"log"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"git.k6n.net/mats/platform/inventory"
)
type CartMutationContext struct {
reservationService inventory.CartReservationService
reservationService inventory.ReservationPolicy
}
func NewCartMutationContext(reservationService inventory.CartReservationService) *CartMutationContext {
func NewCartMutationContext(reservationService inventory.ReservationPolicy) *CartMutationContext {
return &CartMutationContext{
reservationService: reservationService,
}
@@ -30,7 +32,7 @@ func (c *CartMutationContext) ReserveItem(ctx context.Context, cartId CartId, sk
}
ttl := time.Minute * 15
endTime := time.Now().Add(ttl)
err := c.reservationService.ReserveForCart(ctx, inventory.CartReserveRequest{
err := c.reservationService.Reserve(ctx, inventory.CartReserveRequest{
CartID: inventory.CartID(cartId.String()),
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(sku),
@@ -57,7 +59,7 @@ func (c *CartMutationContext) UseReservations(item *CartItem) bool {
if item.InventoryTracked {
return true
}
return item.Cgm == "55010"
return false
}
func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sku string, locationId *string) error {
@@ -68,7 +70,101 @@ func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sk
if locationId != nil {
l = inventory.LocationID(*locationId)
}
return c.reservationService.ReleaseForCart(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
return c.reservationService.Release(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
}
// equalOptional is the canonical nullable-equality primitive used by the
// cart mutations when deciding whether two identifier fields (parent/child
// links, store ids, anything else nullable) refer to the same thing.
//
// Returns true when both pointers are nil, when both alias the same
// address, or when both are non-nil and dereference to equal values. The
// same-address short-circuit is a no-op for correctness but lets a caller
// who passes the same variable get back true without traversing the
// generic's comparison path.
//
// One tested primitive covers every *T the cart currently compares —
// `*uint32` for ParentId, `*string` for StoreId — and any future
// comparable pointer type. Hand-rolled "both nil OR both non-nil and
// equal" expressions were duplicated at AddItem's merge site and were a
// footgun whenever a new field was added (which axis of the predicate
// was the relevant one?). Keeping a single helper makes future merges
// safer to copy.
func equalOptional[T comparable](a, b *T) bool {
if a == b { // both nil OR same address
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
// CascadeChildQuantities scales every direct child's quantity
// proportionally when a parent's quantity is bumped (ChangeQuantity or
// the AddItem-merge path).
//
// - Ratio: newChildQty = oldChildQty × newParentQty / oldParentQty.
// - Floor: scale-down via integer division can yield 0 (e.g. parent
// 2 → 1, child qty 1 → (1×1)/2 = 0). We clamp to 1 so a scale-down
// never silently deletes an accessory. The mismatch is acceptable —
// shipping one extra child is better than dropping one.
// - Reservations: each child's reservation is released then re-acquired
// at the new quantity if UseReservations is true for that child AND a
// prior reservation existed. A failing reservation is logged, not
// raised, so a single bad child doesn't abort the whole cascade
// (matches the existing removal pattern in mutation_items.go).
// - Recursion: descends into grandchildren using the child's own
// before/after quantities as the next ratio's input, so deeply
// nested accessory trees (parent → child → grandchild) stay in
// ratio with the top-level change.
//
// Caller is responsible for locking any grain mutex and for re-running
// UpdateTotals after the cascade has settled.
func (c *CartMutationContext) CascadeChildQuantities(
ctx context.Context,
g *CartGrain,
parentLineId uint32,
oldParentQty uint16,
newParentQty uint16,
) {
if oldParentQty == 0 || oldParentQty == newParentQty {
return
}
for _, it := range g.Items {
if it == nil {
continue
}
if it.ParentId == nil || *it.ParentId != parentLineId {
continue
}
oldChildQty := it.Quantity
ratio := uint32(newParentQty) * uint32(oldChildQty) / uint32(oldParentQty)
newChildQty := uint16(ratio)
if newChildQty == 0 {
newChildQty = 1
}
if newChildQty == oldChildQty {
continue
}
if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(it) && it.ReservationEndTime != nil {
if err := c.ReleaseItem(ctx, g.Id, it.Sku, it.StoreId); err != nil {
log.Printf("CascadeChildQuantities: failed to release %s: %v", it.Sku, err)
}
endTime, err := c.ReserveItem(ctx, g.Id, it.Sku, it.StoreId, newChildQty)
if err != nil {
log.Printf("CascadeChildQuantities: failed to reserve %s at qty %d: %v", it.Sku, newChildQty, err)
} else if endTime != nil {
it.ReservationEndTime = endTime
}
}
it.Quantity = newChildQty
// Descend so grandchildren track this child's ratio.
c.CascadeChildQuantities(ctx, g, it.Id, oldChildQty, newChildQty)
}
}
func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry {
@@ -87,6 +183,8 @@ func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegist
actor.NewMutation(RemoveLineItemMarking),
actor.NewMutation(SetLineItemCustomFields),
actor.NewMutation(SubscriptionAdded),
actor.NewMutation(context.SetCartType),
actor.NewMutation(SetRecoveryContact),
// actor.NewMutation(SubscriptionRemoved),
)
return reg
+68
View File
@@ -0,0 +1,68 @@
package cart
import "testing"
// equalOptional is the nullable-equality primitive shared by the AddItem
// merge site and any future field-comparison. Two call sites use it today
// with two distinct types — *uint32 (ParentId) and *string (StoreId) — so
// the test pins both via generic instantiation.
func TestEqualOptional(t *testing.T) {
t.Run("both nil is equal", func(t *testing.T) {
if !equalOptional[string](nil, nil) {
t.Error("both nil should be equal")
}
if !equalOptional[uint32](nil, nil) {
t.Error("both nil should be equal (uint32)")
}
})
t.Run("same address alias is equal", func(t *testing.T) {
s := "x"
if !equalOptional(&s, &s) {
t.Error("same-address *string should be equal")
}
v := uint32(5)
if !equalOptional(&v, &v) {
t.Error("same-address *uint32 should be equal")
}
})
t.Run("distinct addresses with equal values are equal", func(t *testing.T) {
a, b := "x", "x"
if !equalOptional(&a, &b) {
t.Error("equal strings at distinct addresses should be equal")
}
u, w := uint32(5), uint32(5)
if !equalOptional(&u, &w) {
t.Error("equal uint32 at distinct addresses should be equal")
}
})
t.Run("one nil, one set is not equal", func(t *testing.T) {
a := "x"
if equalOptional(&a, nil) {
t.Error("non-nil vs nil should differ (string)")
}
if equalOptional[string](nil, &a) {
t.Error("nil vs non-nil should differ (string)")
}
u := uint32(5)
if equalOptional(&u, nil) {
t.Error("non-nil vs nil should differ (uint32)")
}
if equalOptional[uint32](nil, &u) {
t.Error("nil vs non-nil should differ (uint32)")
}
})
t.Run("differing values are not equal", func(t *testing.T) {
a, b := "x", "y"
if equalOptional(&a, &b) {
t.Error("differing strings should differ")
}
u, w := uint32(5), uint32(6)
if equalOptional(&u, &w) {
t.Error("differing uint32 should differ")
}
})
}
+95
View File
@@ -0,0 +1,95 @@
package cart
// EvaluatedItem is one line in the per-line promotion breakdown exposed
// alongside a cart. It mirrors the line that was evaluated (sku,
// quantity, unit list price, optional OrgPrice for the strikethrough) and
// adds the per-line discount the engine applied (orgPrice-based +
// promotion-based, additive) plus the resulting effective per-unit and
// per-line totals.
//
// Lives in pkg/cart rather than pkg/promotions so CartGrain can carry the
// breakdown as a derived field (populated by the canonical promotion
// pipeline that already manages TotalDiscount/AppliedPromotions), and so
// every consumer of the grain — UCP cart response, legacy /cart HTTP
// handler, cart-mcp, AMQP mutation feed — naturally sees the same shape
// without ad-hoc wrappers at each call site. JSON tags are the canonical
// EvaluatedItem shape that /promotions/evaluate-with-cart has returned
// since the breakdown feature was introduced.
//
// Distributing the total cart discount down to the line level lets the
// storefront render "Item X: 100 kr → 80 kr" without re-doing the math
// on the client and lets verifiers confirm which promotion hit which
// line (per-line DiscountIncVat is the engine's authoritative answer).
type EvaluatedItem struct {
Sku string `json:"sku"`
Name string `json:"name,omitempty"`
Quantity uint16 `json:"qty"`
PriceIncVat int64 `json:"priceIncVat"` // per-unit list price
OrgPriceIncVat int64 `json:"orgPriceIncVat,omitempty"` // per-unit pre-discount list price (for strikethrough)
DiscountIncVat int64 `json:"discountIncVat"` // per-line TOTAL discount (orgPrice + promotion), incVat in öre
EffectivePriceIncVat int64 `json:"effectivePriceIncVat"` // per-unit, after discount, incVat in öre (rounded)
EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"` // per-line, after discount, incVat in öre (exact)
}
// MapEvaluatedItems walks the grain's items and produces the per-line
// EvaluatedItem list. The grain's item.Discount carries the TOTAL per-line
// discount (orgPrice + promotion — additive, set by UpdateTotals and the
// effects' per-line distribution). The effective totals are derived as
// (price * qty - discount), clamped to 0 (a promotion that over-discounted
// a line shows as free, not negative). The effective per-unit price is the
// per-line total divided by quantity, rounded to the nearest öre so the
// per-unit display matches the per-line total when multiplied back.
//
// Returns nil for a nil grain (callers that always pass a non-nil grain
// — the typical case — get a non-nil empty slice for an empty cart, which
// is the desired behavior for JSON omitempty at the consumer).
//
// Exported so the canonical promotion pipeline (pkg/promotions.EvaluateAndApply)
// and the manual /promotions/evaluate-with-cart preview handler can produce
// the same per-line shape.
func MapEvaluatedItems(g *CartGrain) []EvaluatedItem {
if g == nil {
return nil
}
out := make([]EvaluatedItem, 0, len(g.Items))
for _, it := range g.Items {
if it == nil {
continue
}
var name string
if it.Meta != nil {
name = it.Meta.Name
}
var orgPrice int64
if it.OrgPrice != nil {
orgPrice = it.OrgPrice.IncVat.Int64()
}
var discount int64
if it.Discount != nil {
discount = it.Discount.IncVat.Int64()
}
priceRow := it.Price.IncVat.Int64() * int64(it.Quantity)
effTotal := priceRow - discount
if effTotal < 0 {
effTotal = 0
}
var effUnit int64
if it.Quantity > 0 {
// Nearest-öre rounding so per-unit * qty matches the
// per-line total (within 1 öre either way). Half-up:
// (effTotal + qty/2) / qty.
effUnit = (effTotal + int64(it.Quantity)/2) / int64(it.Quantity)
}
out = append(out, EvaluatedItem{
Sku: it.Sku,
Name: name,
Quantity: it.Quantity,
PriceIncVat: it.Price.IncVat.Int64(),
OrgPriceIncVat: orgPrice,
DiscountIncVat: discount,
EffectivePriceIncVat: effUnit,
EffectiveTotalIncVat: effTotal,
})
}
return out
}
-177
View File
@@ -1,177 +0,0 @@
package cart
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"time"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"google.golang.org/protobuf/types/known/timestamppb"
)
// decodeExtra unpacks the dynamic product data carried as raw JSON. Invalid
// payloads are logged and dropped rather than failing the mutation.
func decodeExtra(b []byte) map[string]json.RawMessage {
if len(b) == 0 {
return nil
}
m := map[string]json.RawMessage{}
if err := json.Unmarshal(b, &m); err != nil {
log.Printf("AddItem: invalid extra_json: %v", err)
return nil
}
return m
}
// mutation_add_item.go
//
// Registers the AddItem cart mutation in the generic mutation registry.
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
//
// Behavior:
// - Validates quantity > 0
// - If an item with the same item id (ItemId) exists -> increases quantity
// - Else creates a new CartItem with computed tax amounts
// - Totals recalculated automatically via WithTotals()
//
// Item identity is the catalog item id (ItemId), not the SKU: the product
// service is looked up by id and the returned SKU is reference-only.
//
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
// must keep this handler in sync.
var ErrPaymentInProgress = errors.New("payment in progress")
func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) error {
ctx := context.Background()
if m == nil {
return fmt.Errorf("AddItem: nil payload")
}
if m.Quantity < 1 {
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
}
// Merge with any existing item having the same item id and matching StoreId
// (including both nil). Identity is the id; SKU is reference-only.
for _, existing := range g.Items {
if existing.ItemId != m.ItemId {
continue
}
sameStore := (existing.StoreId == nil && m.StoreId == nil) ||
(existing.StoreId != nil && m.StoreId != nil && *existing.StoreId == *m.StoreId)
if !sameStore {
continue
}
if c.UseReservations(existing) {
if err := c.ReleaseItem(ctx, g.Id, existing.Sku, existing.StoreId); err != nil {
log.Printf("failed to release item %d: %v", existing.Id, err)
}
endTime, err := c.ReserveItem(ctx, g.Id, existing.Sku, existing.StoreId, existing.Quantity+uint16(m.Quantity))
if err != nil {
return err
}
existing.ReservationEndTime = endTime
}
existing.Quantity += uint16(m.Quantity)
existing.Stock = uint16(m.Stock)
existing.InventoryTracked = m.InventoryTracked
existing.DropShip = m.DropShip
// If existing had nil store but new has one, adopt it.
if existing.StoreId == nil && m.StoreId != nil {
existing.StoreId = m.StoreId
}
// Refresh dynamic product data with the latest payload.
if extra := decodeExtra(m.ExtraJson); extra != nil {
existing.Extra = extra
}
// Replace custom fields when provided on the re-add.
if len(m.CustomFields) > 0 {
existing.CustomFields = m.CustomFields
}
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
g.lastItemId++
// m.Tax is the rate in basis points (2500 = 25%, 1250 = 12.5%) — the single
// platform scale; flows straight through, no conversion.
rateBp := 2500
if m.Tax > 0 {
rateBp = int(m.Tax)
}
pricePerItem := NewPriceFromIncVat(m.Price, rateBp)
needsReservation := true
if m.ReservationEndTime != nil {
needsReservation = m.ReservationEndTime.AsTime().Before(time.Now())
}
cartItem := &CartItem{
Id: g.lastItemId,
ItemId: uint32(m.ItemId),
Quantity: uint16(m.Quantity),
Sku: m.Sku,
Tax: rateBp,
Meta: &ItemMeta{
Name: m.Name,
Image: m.Image,
Brand: m.Brand,
Category: m.Category,
Category2: m.Category2,
Category3: m.Category3,
Category4: m.Category4,
Category5: m.Category5,
Outlet: m.Outlet,
SellerName: m.SellerName,
},
SellerId: m.SellerId,
Cgm: m.Cgm,
SaleStatus: m.SaleStatus,
ParentId: m.ParentId,
Price: *pricePerItem,
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
Stock: uint16(m.Stock),
Disclaimer: m.Disclaimer,
OrgPrice: getOrgPrice(m.OrgPrice, rateBp),
ArticleType: m.ArticleType,
StoreId: m.StoreId,
Extra: decodeExtra(m.ExtraJson),
CustomFields: m.CustomFields,
InventoryTracked: m.InventoryTracked,
DropShip: m.DropShip,
}
if needsReservation && c.UseReservations(cartItem) {
endTime, err := c.ReserveItem(ctx, g.Id, m.Sku, m.StoreId, uint16(m.Quantity))
if err != nil {
return err
}
if endTime != nil {
m.ReservationEndTime = timestamppb.New(*endTime)
t := m.ReservationEndTime.AsTime()
cartItem.ReservationEndTime = &t
}
}
g.Items = append(g.Items, cartItem)
g.UpdateTotals()
return nil
}
func getOrgPrice(orgPrice int64, rateBp int) *Price {
if orgPrice <= 0 {
return nil
}
return NewPriceFromIncVat(orgPrice, rateBp)
}
+152
View File
@@ -38,3 +38,155 @@ func TestAddItem_MergesByItemIdNotSku(t *testing.T) {
t.Fatalf("items = %d, want 2", len(g.Items))
}
}
// Re-adding the same parent (same ItemId + StoreId, no ParentId on the
// re-add itself) merges the quantity, and the proportional cascade resizes
// every direct child to match the new parent qty so a "+1 drill" leaves
// the bundle internally consistent.
func TestAddItem_MergeCascadesToChildren(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
t.Fatalf("add parent: %v", err)
}
parentLine := g.Items[0].Id
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine}); err != nil {
t.Fatalf("add child: %v", err)
}
// Re-add the same parent — same ItemId, no ParentId on the re-add itself.
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
t.Fatalf("re-add parent: %v", err)
}
// Parent qty 1 -> 2; child qty 1 -> 2 via cascade.
for _, it := range g.Items {
if it.Quantity != 2 {
t.Errorf("line %s qty = %d, want 2 (merge cascade)", it.Sku, it.Quantity)
}
}
}
// Item identity for merge purposes includes ParentId: a standalone root
// line (ParentId == nil) and a child line (ParentId == &someParentLine) that
// happen to share an ItemId are different roles and MUST stay as distinct
// lines. Merging would silently turn an accessory into a +N on the root
// (or vice versa), corrupting the parent-child link.
func TestAddItem_DoesNotMergeWhenParentIdDiffers(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
// Standalone root (no ParentId).
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
t.Fatalf("add standalone: %v", err)
}
parentLine := g.Items[0].Id
// Same ItemId+StoreId but with ParentId set — must create a NEW line,
// not bump the standalone's qty.
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000, ParentId: &parentLine}); err != nil {
t.Fatalf("add child-form: %v", err)
}
if len(g.Items) != 2 {
t.Fatalf("items = %d, want 2 (different ParentId => distinct lines)", len(g.Items))
}
// Standalone untouched at qty 1; child-form is its own line at qty 1.
for _, it := range g.Items {
if it.Quantity != 1 {
t.Errorf("line %d (parentId=%v) qty = %d, want 1 (no merge)", it.Id, it.ParentId, it.Quantity)
}
// One of them has nil ParentId and the other has &parentLine — guard
// against an accidental merge ever flipping both to non-nil.
}
var seenNil, seenSet bool
for _, it := range g.Items {
if it.ParentId == nil {
seenNil = true
} else if *it.ParentId == parentLine {
seenSet = true
}
}
if !seenNil || !seenSet {
t.Errorf("expected one line with nil ParentId and one with &parentLine; got nil=%v set=%v", seenNil, seenSet)
}
}
// Positive control: when BOTH sides have ParentId set to the SAME parent
// line, the merge proceeds (this guards the new sameParent guard against
// accidentally rejecting a legitimate "re-add same child of same parent"
// case — e.g. an idempotent retry from a flaky request).
func TestAddItem_MergesWhenBothParentIdMatch(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
t.Fatalf("add parent: %v", err)
}
parentLine := g.Items[0].Id
// First child: ItemId 200 under parent.
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 200, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentLine}); err != nil {
t.Fatalf("add child: %v", err)
}
// Re-add the SAME child (same ItemId+StoreId+ParentId) — must merge into
// qty 2, not create a second accessory line.
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 200, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentLine}); err != nil {
t.Fatalf("re-add child: %v", err)
}
if len(g.Items) != 2 {
t.Fatalf("items = %d, want 2 (parent + merged child)", len(g.Items))
}
for _, it := range g.Items {
switch it.Id {
case parentLine:
if it.Quantity != 1 {
t.Errorf("parent qty = %d, want 1 (untouched)", it.Quantity)
}
default:
if it.Quantity != 2 {
t.Errorf("child qty = %d, want 2 (merged on same-PARENT re-add)", it.Quantity)
}
}
}
}
// Same ItemId+StoreId+both with ParentId set to *different* parent lines
// must also stay distinct — children belong to their own parent.
func TestAddItem_DoesNotMergeAcrossDifferentParentLines(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P1", Quantity: 1, Price: 500}); err != nil {
t.Fatalf("add parent A: %v", err)
}
parentA := g.Items[0].Id
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 200, Sku: "P2", Quantity: 1, Price: 500}); err != nil {
t.Fatalf("add parent B: %v", err)
}
parentB := g.Items[1].Id
// Two children of different parents sharing an ItemId — distinct lines.
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 999, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentA}); err != nil {
t.Fatalf("add child of A: %v", err)
}
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 999, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentB}); err != nil {
t.Fatalf("add child of B: %v", err)
}
// 2 parent lines + 2 child lines = 4 total; no merges across.
if len(g.Items) != 4 {
t.Errorf("items = %d, want 4 (parents A,B + their distinct children)", len(g.Items))
}
for _, it := range g.Items {
if it.Quantity != 1 {
t.Errorf("line %d qty = %d, want 1 (no accidental merge)", it.Id, it.Quantity)
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package cart
import (
"context"
"testing"
"time"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"git.k6n.net/mats/platform/inventory"
)
type mockReservationPolicy struct {
reserveCount int
releaseCount int
}
func (m *mockReservationPolicy) Reserve(ctx context.Context, req inventory.CartReserveRequest) error {
m.reserveCount++
return nil
}
func (m *mockReservationPolicy) Release(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID, cartID inventory.CartID) error {
m.releaseCount++
return nil
}
func (m *mockReservationPolicy) Available(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID) (int64, error) {
return 100, nil
}
func TestCartType_TransitionsAndReservations(t *testing.T) {
mockPolicy := &mockReservationPolicy{}
mutationCtx := NewCartMutationContext(mockPolicy)
reg := NewCartMultationRegistry(mutationCtx)
g := NewCartGrain(1, time.Now())
ctx := context.Background()
// 1. Initially REGULAR, adding item should trigger reservation
g.Type = cart_messages.CartType_REGULAR
_, err := reg.Apply(ctx, g, &cart_messages.AddItem{
ItemId: 101,
Sku: "SKU-1",
Quantity: 1,
Price: 1000,
InventoryTracked: true,
})
if err != nil {
t.Fatalf("AddItem (REGULAR) failed: %v", err)
}
if mockPolicy.reserveCount != 1 {
t.Errorf("Expected 1 reserve call, got %d", mockPolicy.reserveCount)
}
// 2. Set type to WISHLIST. This should release active reservations.
_, err = reg.Apply(ctx, g, &cart_messages.SetCartType{
Type: cart_messages.CartType_WISHLIST,
})
if err != nil {
t.Fatalf("SetCartType (WISHLIST) failed: %v", err)
}
if g.Type != cart_messages.CartType_WISHLIST {
t.Errorf("Expected type to be WISHLIST, got %v", g.Type)
}
if mockPolicy.releaseCount != 1 {
t.Errorf("Expected 1 release call during transition, got %d", mockPolicy.releaseCount)
}
// 3. AddItem in WISHLIST should NOT trigger reservation
_, err = reg.Apply(ctx, g, &cart_messages.AddItem{
ItemId: 102,
Sku: "SKU-2",
Quantity: 1,
Price: 2000,
InventoryTracked: true,
})
if err != nil {
t.Fatalf("AddItem (WISHLIST) failed: %v", err)
}
if mockPolicy.reserveCount != 1 { // Should still be 1 from step 1
t.Errorf("Expected reserve count to remain 1, got %d", mockPolicy.reserveCount)
}
// 4. Verify simple price sum is updated, but vouchers are not applied
g.Vouchers = append(g.Vouchers, &Voucher{
Code: "DISCOUNT",
Value: 500,
Applied: false,
})
g.UpdateTotals()
// Total price should be sum of 1000 + 2000 = 3000
if g.TotalPrice.IncVat != 3000 {
t.Errorf("Expected total price 3000, got %d", g.TotalPrice.IncVat)
}
if g.Vouchers[0].Applied {
t.Errorf("Expected voucher to NOT be applied in WISHLIST")
}
}
+22 -17
View File
@@ -4,9 +4,8 @@ import (
"context"
"fmt"
"log"
"time"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
// mutation_change_quantity.go
@@ -15,8 +14,11 @@ import (
//
// Behavior:
// - Locates an item by its cart-local line item Id (not source item_id).
// - If requested quantity <= 0 the line is removed.
// - Otherwise the line's Quantity field is updated.
// - If requested quantity <= 0 the line AND any descendants are
// removed via the shared RemoveItem cascade (transitive parent→child).
// - Otherwise the line's Quantity is updated and direct children are
// rescaled proportionally so a "2 of this + 2 of each accessory"
// bundle stays internally consistent when the parent becomes 3.
// - Totals are recalculated (WithTotals).
//
// Error handling:
@@ -29,7 +31,7 @@ import (
// (If strict locking is required around every mutation, wrap logic in
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *cart_messages.ChangeQuantity) error {
if m == nil {
return fmt.Errorf("ChangeQuantity: nil payload")
}
@@ -48,23 +50,18 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua
}
if m.Quantity <= 0 {
// Remove the item
itemToRemove := g.Items[foundIndex]
if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.After(time.Now()) {
err := c.ReleaseItem(ctx, g.Id, itemToRemove.Sku, itemToRemove.StoreId)
if err != nil {
log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId)
}
}
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
g.UpdateTotals()
return nil
// Setting a parent line to 0 means "remove it AND its accessories".
// Delegate to RemoveItem so the cascade logic in one place stays
// the source of truth for descendant cleanup + reservation
// release — otherwise we'd duplicate the transitive cascade here.
return c.RemoveItem(g, &cart_messages.RemoveItem{Id: m.Id})
}
item := g.Items[foundIndex]
if item == nil {
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
}
if c.UseReservations(item) {
oldQty := item.Quantity
if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(item) {
if item.ReservationEndTime != nil {
err := c.ReleaseItem(ctx, g.Id, item.Sku, item.StoreId)
if err != nil {
@@ -79,6 +76,14 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua
item.ReservationEndTime = endTime
}
item.Quantity = uint16(m.Quantity)
// Rescale any direct child lines proportionally so accessories stay
// in sync with their parent. Descends recursively into grandchildren
// via CascadeChildQuantities itself. Skipped when the target itself
// is a child line — direct qty edits on a child are treated as
// explicit user intent and don't propagate further.
if item.ParentId == nil {
c.CascadeChildQuantities(ctx, g, item.Id, oldQty, item.Quantity)
}
g.UpdateTotals()
return nil
+170
View File
@@ -0,0 +1,170 @@
package cart
import (
"context"
"testing"
"time"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
// Parent qty 1 → 2 resizes every direct child proportionally. Children all
// start at qty 1 so the proportional factor of 2 doubles them.
func TestChangeQuantity_ParentCascadesDoublingChildren(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
parentLine := g.Items[0].Id
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "C2", Quantity: 1, Price: 200, ParentId: &parentLine})
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 2}); err != nil {
t.Fatalf("change qty: %v", err)
}
if g.Items[0].Quantity != 2 {
t.Errorf("parent qty = %d, want 2", g.Items[0].Quantity)
}
for _, it := range g.Items {
if it.Id == parentLine {
continue
}
if it.Quantity != 2 {
t.Errorf("child %s qty = %d, want 2 (cascaded from parent)", it.Sku, it.Quantity)
}
}
}
// Parent qty 2 → 3 with children at qty 2 → 3 (factor 3/2 = 1.5, integer math).
func TestChangeQuantity_ParentCascadesNonIntegerRatio(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 2, Price: 1000})
parentLine := g.Items[0].Id
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 2, Price: 100, ParentId: &parentLine})
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 3}); err != nil {
t.Fatalf("change qty: %v", err)
}
// parent: 2 -> 3 (direct set), child: 2 * 3 / 2 = 3
if g.Items[0].Quantity != 3 {
t.Errorf("parent qty = %d, want 3", g.Items[0].Quantity)
}
if g.Items[1].Quantity != 3 {
t.Errorf("child qty = %d, want 3 (proportional scale)", g.Items[1].Quantity)
}
}
// Scale-down that would round to 0 (parent 2 → 1, child qty 1 → (1*1)/2 = 0)
// must clamp to 1 so the child is never silently dropped by integer math.
func TestChangeQuantity_ParentScaleDownFloorsAtOne(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 2, Price: 1000})
parentLine := g.Items[0].Id
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 1}); err != nil {
t.Fatalf("change qty: %v", err)
}
if g.Items[0].Quantity != 1 {
t.Errorf("parent qty = %d, want 1", g.Items[0].Quantity)
}
if g.Items[1].Quantity != 1 {
t.Errorf("child qty = %d, want 1 (floor; integer math dropped to 0 without clamp)", g.Items[1].Quantity)
}
}
// Setting parent qty to 0 delegates to RemoveItem so the existing parent→
// child cascade takes over. Unrelated lines are left untouched.
func TestChangeQuantity_ZeroCascadesRemoval(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
parentLine := g.Items[0].Id
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 200, Sku: "OTHER", Quantity: 1, Price: 500})
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 0}); err != nil {
t.Fatalf("change qty 0: %v", err)
}
if len(g.Items) != 1 {
t.Fatalf("items = %d, want 1 (only OTHER survives)", len(g.Items))
}
if g.Items[0].Sku != "OTHER" {
t.Errorf("remaining sku = %q, want OTHER", g.Items[0].Sku)
}
}
// Changing a child's qty directly updates only that child — siblings and
// the parent are untouched. (The UI no longer exposes this control, but
// the HTTP surface still allows it for tools & direct API calls.)
func TestChangeQuantity_ChildDoesNotCascade(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
parentLine := g.Items[0].Id
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "C2", Quantity: 1, Price: 200, ParentId: &parentLine})
targetChild := g.Items[1].Id
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: targetChild, Quantity: 3}); err != nil {
t.Fatalf("change child qty: %v", err)
}
for _, it := range g.Items {
switch it.Id {
case parentLine:
if it.Quantity != 1 {
t.Errorf("parent qty = %d, want 1 (untouched on child edit)", it.Quantity)
}
case targetChild:
if it.Quantity != 3 {
t.Errorf("target child qty = %d, want 3", it.Quantity)
}
default:
// Sibling
if it.Quantity != 1 {
t.Errorf("sibling %s qty = %d, want 1 (untouched on child edit)", it.Sku, it.Quantity)
}
}
}
}
// Cascade descends into grandchildren so deeply nested accessory trees
// remain internally consistent with the top-level parent change.
func TestChangeQuantity_NestedChildren(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
parentLine := g.Items[0].Id
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
childLine := g.Items[1].Id
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "G1", Quantity: 1, Price: 50, ParentId: &childLine})
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 2}); err != nil {
t.Fatalf("change qty: %v", err)
}
// parent 1 -> 2; child 1 -> 2 (parent -> child cascade); grandchild 1 -> 2 (child -> grandchild cascade)
for _, it := range g.Items {
if it.Quantity != 2 {
t.Errorf("line %s qty = %d, want 2 (nested cascade)", it.Sku, it.Quantity)
}
}
}
+297
View File
@@ -0,0 +1,297 @@
// mutation_items.go
//
// Single concern file for the cart line-item lifecycle: AddItem +
// RemoveItem (and the helpers they share). Pulled out of the per-mutation
// file layout as a proof-of-grouping for the pattern in
// pkg/cart/MUTATIONS.md. AddItem and RemoveItem are dispatched through the
// same MutationRegistry (cart-mutation-helper.go) and operate on the same
// *CartGrain, so keeping them adjacent lets future reviewers compare
// "what does add do when an item already exists" against "what does
// remove do when a parent is removed" without bouncing between files.
//
// What lives here:
//
// - AddItem: validation, merge-into-existing by ItemId, reservation
// handling, totals.
// - RemoveItem: by-line-id removal with transitive parent→child
// cascade, reservation release, totals.
// - Shared package helpers: decodeExtra (dynamic product extra JSON),
// getOrgPrice (origin price helper), ErrPaymentInProgress (used by
// mutation_add_voucher.go to refuse stacking during checkout).
//
// Out of scope here: change_quantity, set_custom_fields, line markings,
// subscription details, etc. Those are separate concerns and stay in
// their own files until they grow enough behaviour to invite merging.
package cart
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"time"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"google.golang.org/protobuf/types/known/timestamppb"
)
// ErrPaymentInProgress is returned by mutations that must not proceed
// while the cart is mid-payment (currently referenced by
// mutation_add_voucher.go to short-circuit adding a voucher mid-checkout).
var ErrPaymentInProgress = errors.New("payment in progress")
// decodeExtra unpacks the dynamic product data carried as raw JSON. Invalid
// payloads are logged and dropped rather than failing the mutation.
func decodeExtra(b []byte) map[string]json.RawMessage {
if len(b) == 0 {
return nil
}
m := map[string]json.RawMessage{}
if err := json.Unmarshal(b, &m); err != nil {
log.Printf("AddItem: invalid extra_json: %v", err)
return nil
}
return m
}
func getOrgPrice(orgPrice int64, rateBp int) *Price {
if orgPrice <= 0 {
return nil
}
return NewPriceFromIncVat(orgPrice, rateBp)
}
// AddItem (formerly mutation_add_item.go).
//
// Registers the AddItem cart mutation in the generic mutation registry.
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
//
// Behaviour:
// - Validates quantity > 0
// - If an item with the same item id (ItemId) exists -> increases quantity
// - Else creates a new CartItem with computed tax amounts
// - Totals recalculated automatically via WithTotals()
//
// Item identity is the catalog item id (ItemId), not the SKU: the product
// service is looked up by id and the returned SKU is reference-only.
//
// NOTE: Any future field additions in messages.AddItem that affect pricing /
// tax must keep this handler in sync.
func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) error {
ctx := context.Background()
if m == nil {
return fmt.Errorf("AddItem: nil payload")
}
if m.Quantity < 1 {
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
}
// Merge with any existing item having the same item id, matching
// StoreId, and matching ParentId (all three including both nil).
// Identity is ItemId; SKU is reference-only.
//
// ParentId MUST match: a standalone root line (ParentId == nil) and
// a child line (ParentId == &someParentLine) that happen to share an
// ItemId are different roles in the cart, even though the catalog
// item is the same. Merging them would silently convert an accessory
// into a +N on the root (or vice versa), corrupting the parent-child
// link. Both checks are delegated to equalOptional so the nullable-
// equality idiom stays in one place (cart-mutation-helper.go).
for _, existing := range g.Items {
if existing.ItemId != m.ItemId {
continue
}
if !equalOptional(existing.StoreId, m.StoreId) {
continue
}
if !equalOptional(existing.ParentId, m.ParentId) {
continue
}
if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(existing) {
if err := c.ReleaseItem(ctx, g.Id, existing.Sku, existing.StoreId); err != nil {
log.Printf("failed to release item %d: %v", existing.Id, err)
}
endTime, err := c.ReserveItem(ctx, g.Id, existing.Sku, existing.StoreId, existing.Quantity+uint16(m.Quantity))
if err != nil {
return err
}
existing.ReservationEndTime = endTime
}
oldQty := existing.Quantity
existing.Quantity += uint16(m.Quantity)
existing.Stock = uint16(m.Stock)
existing.InventoryTracked = m.InventoryTracked
existing.DropShip = m.DropShip
// If existing had nil store but new has one, adopt it.
if existing.StoreId == nil && m.StoreId != nil {
existing.StoreId = m.StoreId
}
// Refresh dynamic product data with the latest payload.
if extra := decodeExtra(m.ExtraJson); extra != nil {
existing.Extra = extra
}
// Replace custom fields when provided on the re-add.
if len(m.CustomFields) > 0 {
existing.CustomFields = m.CustomFields
}
// Re-add grew the parent's qty — rescale direct child quantities
// proportionally so a "+1 drill" keeps its accessories consistent.
// Skip when the merged line IS itself a child (ParentId != nil):
// cascading up the tree isn't a thing — the parent would not know
// a child grew without re-running the promotion engine.
if existing.ParentId == nil {
c.CascadeChildQuantities(ctx, g, existing.Id, oldQty, existing.Quantity)
}
// Recompute totals so TotalPrice/TotalDiscount/Discount annotations
// reflect the merged parent + cascaded children. The non-merge
// (new-line) path below also calls UpdateTotals; the merge path
// would otherwise drift after a "+1" re-add, especially now that
// cascade may have rewritten N child quantities.
g.UpdateTotals()
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
g.lastItemId++
// m.Tax is the rate in basis points (2500 = 25%, 1250 = 12.5%) — the single
// platform scale; flows straight through, no conversion.
rateBp := 2500
if m.Tax > 0 {
rateBp = int(m.Tax)
}
pricePerItem := NewPriceFromIncVat(m.Price, rateBp)
needsReservation := true
if m.ReservationEndTime != nil {
needsReservation = m.ReservationEndTime.AsTime().Before(time.Now())
}
cartItem := &CartItem{
Id: g.lastItemId,
ItemId: uint32(m.ItemId),
Quantity: uint16(m.Quantity),
Sku: m.Sku,
Tax: rateBp,
Meta: &ItemMeta{
Name: m.Name,
Image: m.Image,
Brand: m.Brand,
Category: m.Category,
Category2: m.Category2,
Category3: m.Category3,
Category4: m.Category4,
Category5: m.Category5,
Outlet: m.Outlet,
SellerName: m.SellerName,
},
SellerId: m.SellerId,
Cgm: m.Cgm,
SaleStatus: m.SaleStatus,
ParentId: m.ParentId,
Price: *pricePerItem,
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
Stock: uint16(m.Stock),
Disclaimer: m.Disclaimer,
OrgPrice: getOrgPrice(m.OrgPrice, rateBp),
ArticleType: m.ArticleType,
StoreId: m.StoreId,
Extra: decodeExtra(m.ExtraJson),
CustomFields: m.CustomFields,
InventoryTracked: m.InventoryTracked,
DropShip: m.DropShip,
}
if g.Type == cart_messages.CartType_REGULAR && needsReservation && c.UseReservations(cartItem) {
endTime, err := c.ReserveItem(ctx, g.Id, m.Sku, m.StoreId, uint16(m.Quantity))
if err != nil {
return err
}
if endTime != nil {
m.ReservationEndTime = timestamppb.New(*endTime)
t := m.ReservationEndTime.AsTime()
cartItem.ReservationEndTime = &t
}
}
g.Items = append(g.Items, cartItem)
g.UpdateTotals()
return nil
}
// RemoveItem (formerly mutation_remove_item.go).
//
// Registers the RemoveItem mutation.
//
// Behaviour:
// - Removes the cart line whose local cart line Id == payload.Id
// - Cascades: also removes any line whose ParentId points at a removed line
// (transitively), so removing a parent removes its child sub-articles
// - If no such line exists returns an error
// - Releases reservations for every removed line and recalculates totals
//
// Notes:
// - This removes only the line items; any deliveries referencing a removed
// item are NOT automatically adjusted (mirrors prior logic). If future
// semantics require pruning delivery.item_ids, extend this handler.
// - If multiple lines somehow shared the same Id (should not happen), all
// matches are removed — data integrity relies on unique line Ids.
func (c *CartMutationContext) RemoveItem(g *CartGrain, m *cart_messages.RemoveItem) error {
if m == nil {
return fmt.Errorf("RemoveItem: nil payload")
}
targetID := uint32(m.Id)
found := false
for _, it := range g.Items {
if it.Id == targetID {
found = true
break
}
}
if !found {
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
}
// Collect the target and, transitively, any children pointing at a removed
// line. Loops until no further descendants are found (handles nesting).
remove := map[uint32]bool{targetID: true}
for {
grew := false
for _, it := range g.Items {
if it.ParentId != nil && remove[*it.ParentId] && !remove[it.Id] {
remove[it.Id] = true
grew = true
}
}
if !grew {
break
}
}
kept := g.Items[:0]
for _, it := range g.Items {
if remove[it.Id] {
if it.ReservationEndTime != nil && it.ReservationEndTime.After(time.Now()) {
if err := c.ReleaseItem(context.Background(), g.Id, it.Sku, it.StoreId); err != nil {
log.Printf("unable to release reservation for item %d: %v", it.Id, err)
}
}
continue
}
kept = append(kept, it)
}
g.Items = kept
g.UpdateTotals()
return nil
}
-20
View File
@@ -1,20 +0,0 @@
package cart
import (
"fmt"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {
for i, item := range grain.Items {
if item.Id == req.Id {
grain.Items[i].Marking = &Marking{
Type: req.Type,
Text: req.Marking,
}
return nil
}
}
return fmt.Errorf("item with ID %d not found", req.Id)
}
+32
View File
@@ -0,0 +1,32 @@
package cart
import (
"fmt"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
// LineItemMarking sets a marking on the line item with the given ID.
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {
for i, item := range grain.Items {
if item.Id == req.Id {
grain.Items[i].Marking = &Marking{
Type: req.Type,
Text: req.Marking,
}
return nil
}
}
return fmt.Errorf("item with ID %d not found", req.Id)
}
// RemoveLineItemMarking clears the marking on the line item with the given ID.
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {
for i, item := range grain.Items {
if item.Id == req.Id {
grain.Items[i].Marking = nil
return nil
}
}
return fmt.Errorf("item with ID %d not found", req.Id)
}
-79
View File
@@ -1,79 +0,0 @@
package cart
import (
"context"
"fmt"
"log"
"time"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
// mutation_remove_item.go
//
// Registers the RemoveItem mutation.
//
// Behavior:
// - Removes the cart line whose local cart line Id == payload.Id
// - Cascades: also removes any line whose ParentId points at a removed line
// (transitively), so removing a parent removes its child sub-articles
// - If no such line exists returns an error
// - Releases reservations for every removed line and recalculates totals
//
// Notes:
// - This removes only the line items; any deliveries referencing a removed
// item are NOT automatically adjusted (mirrors prior logic). If future
// semantics require pruning delivery.item_ids you can extend this handler.
// - If multiple lines somehow shared the same Id (should not happen), all
// matches are removed—data integrity relies on unique line Ids.
func (c *CartMutationContext) RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
if m == nil {
return fmt.Errorf("RemoveItem: nil payload")
}
targetID := uint32(m.Id)
found := false
for _, it := range g.Items {
if it.Id == targetID {
found = true
break
}
}
if !found {
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
}
// Collect the target and, transitively, any children pointing at a removed
// line. Loops until no further descendants are found (handles nesting).
remove := map[uint32]bool{targetID: true}
for {
grew := false
for _, it := range g.Items {
if it.ParentId != nil && remove[*it.ParentId] && !remove[it.Id] {
remove[it.Id] = true
grew = true
}
}
if !grew {
break
}
}
kept := g.Items[:0]
for _, it := range g.Items {
if remove[it.Id] {
if it.ReservationEndTime != nil && it.ReservationEndTime.After(time.Now()) {
if err := c.ReleaseItem(context.Background(), g.Id, it.Sku, it.StoreId); err != nil {
log.Printf("unable to release reservation for item %d: %v", it.Id, err)
}
}
continue
}
kept = append(kept, it)
}
g.Items = kept
g.UpdateTotals()
return nil
}
@@ -1,18 +0,0 @@
package cart
import (
"fmt"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {
for i, item := range grain.Items {
if item.Id == req.Id {
grain.Items[i].Marking = nil
return nil
}
}
return fmt.Errorf("item with ID %d not found", req.Id)
}
+40
View File
@@ -0,0 +1,40 @@
package cart
import (
"context"
"fmt"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
func (c *CartMutationContext) SetCartType(g *CartGrain, m *messages.SetCartType) error {
if m == nil {
return fmt.Errorf("SetCartType: nil payload")
}
oldType := g.Type
newType := m.Type
if oldType == newType {
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
g.Type = newType
// If transitioning from regular to wishlist or offer, release any active reservations
if oldType == messages.CartType_REGULAR && (newType == messages.CartType_WISHLIST || newType == messages.CartType_OFFER) {
ctx := context.Background()
for _, item := range g.Items {
if item.ReservationEndTime != nil {
_ = c.ReleaseItem(ctx, g.Id, item.Sku, item.StoreId)
item.ReservationEndTime = nil
}
}
}
g.UpdateTotals()
return nil
}
+52
View File
@@ -0,0 +1,52 @@
package cart
import (
"errors"
"log"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
// SetRecoveryContact replaces the entire recovery-contact bundle on a cart in
// a single event. PUT-style semantics — empty email and an empty token list
// both persist as "no contact attached"; callers wanting to clear should send
// an empty bundle.
//
// Trivial whitespace-only normalization is intentionally NOT done here: the
// notifier layer owns validation (and the LoggingNotifier just logs). A future
// real notifier does its own normalization before sending, so we don't want
// to lose the original input.
func SetRecoveryContact(grain *CartGrain, req *messages.SetRecoveryContact) error {
if req == nil {
return errors.New("SetRecoveryContact: nil payload")
}
grain.Email = req.Email
if len(req.PushTokens) == 0 {
grain.PushTokens = nil
return nil
}
tokens := make([]PushToken, 0, len(req.PushTokens))
dropped := 0
for _, t := range req.PushTokens {
if t == nil {
// Defensive: a malformed wire request left a nil slot in the
// repeated field. Skip rather than crash and surface at debug
// level so a misbehaving client is visible without spamming.
dropped++
continue
}
tokens = append(tokens, PushToken{
Platform: t.Platform,
Token: t.Token,
})
}
if dropped > 0 {
log.Printf("SetRecoveryContact: dropped %d nil push-token entries (malformed wire)", dropped)
}
if len(tokens) == 0 {
grain.PushTokens = nil
return nil
}
grain.PushTokens = tokens
return nil
}
@@ -0,0 +1,105 @@
package cart
import (
"context"
"testing"
"time"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
// TestSetRecoveryContact_ReplacesEntireBundle verifies PUT semantics: a
// second call fully overwrites the first; calling with empty clears it.
func TestSetRecoveryContact_ReplacesEntireBundle(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
// Initial set.
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{
Email: "first@example.com",
PushTokens: []*messages.PushToken{
{Platform: "fcm", Token: "AAA"},
{Platform: "apns", Token: "BBB"},
},
}); err != nil {
t.Fatalf("first set: %v", err)
}
if g.Email != "first@example.com" {
t.Errorf("email = %q, want first@example.com", g.Email)
}
if len(g.PushTokens) != 2 {
t.Fatalf("push tokens = %d, want 2", len(g.PushTokens))
}
if g.PushTokens[0].Platform != "fcm" || g.PushTokens[1].Platform != "apns" {
t.Errorf("platforms = %+v, want [fcm apns]", g.PushTokens)
}
// Replace with one push token.
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{
Email: "second@example.com",
PushTokens: []*messages.PushToken{{Platform: "webpush", Token: "CCC"}},
}); err != nil {
t.Fatalf("replace: %v", err)
}
if g.Email != "second@example.com" {
t.Errorf("after replace email = %q, want second@example.com", g.Email)
}
if len(g.PushTokens) != 1 {
t.Fatalf("after replace push tokens = %d, want 1", len(g.PushTokens))
}
if g.PushTokens[0].Platform != "webpush" {
t.Errorf("after replace platform = %q, want webpush", g.PushTokens[0].Platform)
}
// Clear.
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{}); err != nil {
t.Fatalf("clear: %v", err)
}
if g.Email != "" || len(g.PushTokens) != 0 {
t.Errorf("after clear: email=%q tokens=%d, want empty", g.Email, len(g.PushTokens))
}
}
// TestSetRecoveryContact_NilPayload verifies defensive nil-input behavior.
// We document that nil returns an error (no silent swallow of bad requests).
func TestSetRecoveryContact_NilPayload(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
res, err := reg.Apply(ctx, g, (*messages.SetRecoveryContact)(nil))
// registry.Apply treats typed-nil proto messages as a no-op (see
// mutation_registry.go's "Typed nil" handling). The behavior we want is
// "no mutation applied to grain state" — either via no-op via the
// typed-nil path or via an error in the result list. We assert that the
// grain state was not modified.
if g.Email != "" || len(g.PushTokens) != 0 {
t.Errorf("nil-set should not modify grain: email=%q tokens=%d", g.Email, len(g.PushTokens))
}
_ = res
_ = err
}
// TestSetRecoveryContact_SkipsNilTokens verifies nil sub-message tokens are
// dropped rather than crashing.
func TestSetRecoveryContact_SkipsNilTokens(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(2, time.Now())
ctx := context.Background()
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{
Email: "ok@example.com",
PushTokens: []*messages.PushToken{
nil,
{Platform: "fcm", Token: "REAL"},
nil,
},
}); err != nil {
t.Fatalf("set with nil tokens: %v", err)
}
if len(g.PushTokens) != 1 {
t.Fatalf("push tokens = %d, want 1 (nil entries dropped)", len(g.PushTokens))
}
if g.PushTokens[0].Platform != "fcm" {
t.Errorf("kept platform = %q, want fcm", g.PushTokens[0].Platform)
}
}
+1 -1
View File
@@ -10,6 +10,6 @@ func SetUserId(grain *CartGrain, req *messages.SetUserId) error {
if req.UserId == "" {
return errors.New("user ID cannot be empty")
}
grain.userId = req.UserId
grain.UserId = req.UserId
return nil
}
+59
View File
@@ -0,0 +1,59 @@
package recovery
import (
"context"
"log"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
)
// LoggingNotifier is the v0 default: it writes one INFO line per candidate
// and returns nil. It deliberately does NOT pretend to send anything.
//
// Future implementations (MailerSend, FCM, APNs) implement the same Notifier
// interface; the scanner doesn't change. LoggingNotifier also serves as the
// "dry-run" mode in production by leaving the seam unconfigured.
type LoggingNotifier struct{}
// NewLoggingNotifier returns a Notifier that logs every candidate. The struct
// is empty today; the constructor is kept for parity with future notifiers
// that need credentials / config.
func NewLoggingNotifier() *LoggingNotifier { return &LoggingNotifier{} }
// NotifyAbandoned logs the cart id, contact present, and a tiny summary.
// Push tokens are summarized by platform only — never log the raw token, so
// a misconfigured log aggregator can't leak device handles.
func (LoggingNotifier) NotifyAbandoned(_ context.Context, c RecoveryCandidate) error {
hasEmail := c.Email != ""
hasPush := len(c.PushTokens) > 0
channel := "none"
switch {
case hasEmail && hasPush:
channel = "email+push"
case hasEmail:
channel = "email"
case hasPush:
channel = "push"
}
log.Printf(
"recovery [dry-run]: cart=%d user=%q channel=%s items=%d total=%d currency=%s email=%q pushPlatforms=%v lastChangeUnix=%d",
c.CartID,
c.UserID,
channel,
c.ItemCount,
c.TotalIncVat,
c.Currency,
c.Email,
platformsOf(c.PushTokens),
c.LastChangeUnix,
)
return nil
}
func platformsOf(tokens []cart.PushToken) []string {
out := make([]string, 0, len(tokens))
for _, t := range tokens {
out = append(out, t.Platform)
}
return out
}
@@ -0,0 +1,94 @@
package recovery
import (
"bytes"
"context"
"log"
"strings"
"testing"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
)
// captureLog writes log output to an in-memory buffer for inspection.
func captureLog(t *testing.T) *bytes.Buffer {
t.Helper()
buf := &bytes.Buffer{}
orig := log.Writer()
flags := log.Flags()
prefix := log.Prefix()
log.SetOutput(buf)
log.SetFlags(0)
log.SetPrefix("")
t.Cleanup(func() {
log.SetOutput(orig)
log.SetFlags(flags)
log.SetPrefix(prefix)
})
return buf
}
func TestLoggingNotifier_LogsPlatformsNotTokens(t *testing.T) {
buf := captureLog(t)
n := LoggingNotifier{}
err := n.NotifyAbandoned(context.Background(), RecoveryCandidate{
CartID: 1,
Email: "doc@example.com",
PushTokens: []cart.PushToken{{Platform: "fcm", Token: "SECRET-TOKEN-MUST-NOT-LEAK"}, {Platform: "apns", Token: "ALSO-SECRET-2"}},
})
if err != nil {
t.Fatalf("NotifyAbandoned: %v", err)
}
out := buf.String()
if !strings.Contains(out, "doc@example.com") {
t.Errorf("expected email in log: %s", out)
}
if !strings.Contains(out, "[fcm apns]") {
t.Errorf("expected bracketed platform list in log: %s", out)
}
for _, leak := range []string{"SECRET-TOKEN-MUST-NOT-LEAK", "ALSO-SECRET-2"} {
if strings.Contains(out, leak) {
t.Errorf("token leaked into log: %s line=%s", leak, out)
}
}
}
func TestLoggingNotifier_ClassifiesChannel(t *testing.T) {
buf := captureLog(t)
n := LoggingNotifier{}
cases := []struct {
name string
cand RecoveryCandidate
wantSub string
}{
{
name: "email+push",
cand: RecoveryCandidate{Email: "a@b.c", PushTokens: []cart.PushToken{{Platform: "fcm"}}},
wantSub: "channel=email+push",
},
{
name: "email only",
cand: RecoveryCandidate{Email: "a@b.c"},
wantSub: "channel=email",
},
{
name: "push only",
cand: RecoveryCandidate{PushTokens: []cart.PushToken{{Platform: "fcm"}}},
wantSub: "channel=push",
},
{
name: "no contact",
cand: RecoveryCandidate{},
wantSub: "channel=none",
},
}
for _, tc := range cases {
buf.Reset()
if err := n.NotifyAbandoned(context.Background(), tc.cand); err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if !strings.Contains(buf.String(), tc.wantSub) {
t.Errorf("%s: log missing %q; got: %s", tc.name, tc.wantSub, buf.String())
}
}
}
+49
View File
@@ -0,0 +1,49 @@
// Package recovery is the v0 abandoned-cart-recovery seam.
//
// The contract is intentionally narrow: a Scanner finds candidates whose last
// mutation is within an "abandoned" window and that have BOTH an attachable
// contact (email and/or push tokens) and items in the cart, then hands them
// to a Notifier. The Notifier decides what to do — for now a logging shim.
//
// The seam intentionally avoids:
//
// - Email/push-token delivery (lives in the notifier impl).
// - Idempotency tracking ("did we already notify?"). v0: scanner windows
// are sized so a cart falls in once per detection threshold. Real
// idempotency is the next pass — see suggest_followups / plan-commerce-
// maturity C3.
// - Cross-pod coordination. Each cart pod owns its own shard of event logs
// (replicated read cache, no leader), so per-pod scanning is correct.
// A future AMQP fan-out of `cart.recovery.candidate` would let a single
// dedicated notifier consume instead — out of scope here.
package recovery
import (
"context"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
)
// RecoveryCandidate is the projectable "we'd remind this shopper" shape.
// Fields surface just enough for a future email template / push payload; the
// underlying grain carries everything else.
type RecoveryCandidate struct {
CartID cart.CartId
UserID string // empty if the cart hasn't been linked to a profile
Email string
PushTokens []cart.PushToken
ItemCount int
TotalIncVat int64 // minor units (money.Cents == int64)
Currency string
// LastChange is best-effort: the scanner reads the event-log file's
// modification time. A pending notifier shouldn't use it as the canonical
// "when was this cart touched?" wall clock — see cart.LastChange.
LastChangeUnix int64
}
// Notifier is the seam a real email/push implementation plugs into. The
// LoggingNotifier is the v0 default. The cart service ships the seam even
// before a real send exists so call-sites and tracking can be wired once.
type Notifier interface {
NotifyAbandoned(ctx context.Context, c RecoveryCandidate) error
}
+257
View File
@@ -0,0 +1,257 @@
package recovery
import (
"context"
"log"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"google.golang.org/protobuf/proto"
)
// writeFixtureLog writes a synthetic `<id>.events.log` to dir with the given
// mutations, then pins the file's mtime AFTER Close so the canonical
// DiskStorage.SaveLoop flush can't overwrite it on test machines that run
// the inner save() during teardown.
func writeFixtureLog(t *testing.T, dir string, id uint64, mtime time.Time, muts []proto.Message) {
t.Helper()
path := filepath.Join(dir, strconv.FormatUint(id, 10)+".events.log")
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
if err := storage.AppendMutations(id, muts...); err != nil {
t.Fatalf("AppendMutations for fixture %d: %v", id, err)
}
// DiskStorage.Close runs save() which opens+appends the file; that
// updates mtime. Pin AFTER Close so the test mtime wins.
storage.Close()
if err := os.Chtimes(path, mtime, mtime); err != nil {
t.Fatalf("Chtimes %s: %v", path, err)
}
}
// silenceLogs redirects the standard logger while the scanner runs so test
// output stays readable.
func silenceLogs(t *testing.T) {
t.Helper()
orig := log.Writer()
prevFlags := log.Flags()
log.SetOutput(devNull{})
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(orig)
log.SetFlags(prevFlags)
})
}
type devNull struct{}
func (devNull) Write(p []byte) (int, error) { return len(p), nil }
func TestScanner_FindsCandidateInWindow(t *testing.T) {
silenceLogs(t)
dir := t.TempDir()
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
// Cart that became eligible exactly in the middle of the window:
// delay 1h, window 1h → eligible mtime ∈ [now-2h, now-1h].
mtime := now.Add(-90 * time.Minute)
writeFixtureLog(t, dir, 42, mtime, []proto.Message{
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000, Sku: "A"},
&messages.SetRecoveryContact{Email: "abandoned@example.com"},
})
defer storage.Close()
scanner := NewScanner(dir, storage)
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != 1 {
t.Fatalf("candidates = %d, want 1; full result=%+v", len(got), got)
}
c := got[0]
if c.CartID != cart.CartId(42) {
t.Errorf("CartID = %d, want 42", c.CartID)
}
if c.Email != "abandoned@example.com" {
t.Errorf("Email = %q, want abandoned@example.com", c.Email)
}
if c.ItemCount != 1 {
t.Errorf("ItemCount = %d, want 1", c.ItemCount)
}
if c.TotalIncVat != 1000 {
t.Errorf("TotalIncVat = %d, want 1000 (AddItem price 1000)", c.TotalIncVat)
}
}
func TestScanner_SkipsCartsOutsideWindow(t *testing.T) {
silenceLogs(t)
dir := t.TempDir()
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
// Too fresh (mtime = now-30m, inside the 1h "still active" cutoff).
fresh := now.Add(-30 * time.Minute)
writeFixtureLog(t, dir, 7, fresh, []proto.Message{
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
&messages.SetRecoveryContact{Email: "fresh@example.com"},
})
// Too old (mtime = now-25h, past the 2h window upper bound).
old := now.Add(-25 * time.Hour)
writeFixtureLog(t, dir, 8, old, []proto.Message{
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
&messages.SetRecoveryContact{Email: "old@example.com"},
})
// Inside window with NO contact (eligibility gate).
mid := now.Add(-90 * time.Minute)
writeFixtureLog(t, dir, 9, mid, []proto.Message{
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
// no SetRecoveryContact
})
defer storage.Close()
scanner := NewScanner(dir, storage)
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != 0 {
t.Fatalf("candidates = %d, want 0 (all filtered out); result=%+v", len(got), got)
}
}
func TestScanner_FindsPushOnlyCandidate(t *testing.T) {
silenceLogs(t)
dir := t.TempDir()
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
mtime := now.Add(-75 * time.Minute)
writeFixtureLog(t, dir, 11, mtime, []proto.Message{
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
&messages.SetRecoveryContact{
PushTokens: []*messages.PushToken{
{Platform: "fcm", Token: "tok-1"},
{Platform: "apns", Token: "tok-2"},
},
},
})
defer storage.Close()
scanner := NewScanner(dir, storage)
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != 1 {
t.Fatalf("candidates = %d, want 1", len(got))
}
if got[0].Email != "" {
t.Errorf("Email = %q, want empty (push-only)", got[0].Email)
}
if len(got[0].PushTokens) != 2 {
t.Fatalf("PushTokens = %d, want 2", len(got[0].PushTokens))
}
}
func TestScanner_IgnoresWishlistCarts(t *testing.T) {
silenceLogs(t)
dir := t.TempDir()
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
mtime := now.Add(-90 * time.Minute)
writeFixtureLog(t, dir, 13, mtime, []proto.Message{
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
&messages.SetRecoveryContact{Email: "wishlist@example.com"},
&messages.SetCartType{Type: messages.CartType_WISHLIST},
})
defer storage.Close()
scanner := NewScanner(dir, storage)
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != 0 {
t.Fatalf("wishlist candidate leaked: got %+v", got)
}
}
// TestScanner_TotalPostReplay verifies the bug fix: replay does not run the
// post-mutation processor, so the scanner MUST call UpdateTotals to surface
// an accurate TotalIncVat.
func TestScanner_TotalPostReplay(t *testing.T) {
silenceLogs(t)
dir := t.TempDir()
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
mtime := now.Add(-75 * time.Minute)
writeFixtureLog(t, dir, 21, mtime, []proto.Message{
&messages.AddItem{ItemId: 1, Quantity: 2, Price: 500, Sku: "A"},
&messages.AddItem{ItemId: 2, Quantity: 1, Price: 1500, Sku: "B"},
&messages.SetRecoveryContact{Email: "totals@example.com"},
})
defer storage.Close()
scanner := NewScanner(dir, storage)
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != 1 {
t.Fatalf("candidates = %d, want 1", len(got))
}
// 2*500 + 1*1500 = 2500 inc-vat
if got[0].TotalIncVat != 2500 {
t.Errorf("TotalIncVat = %d, want 2500 (1000+1500 across two lines)", got[0].TotalIncVat)
}
if got[0].ItemCount != 2 {
t.Errorf("ItemCount = %d, want 2", got[0].ItemCount)
}
}
// TestIsValidEmail sanity for the optional helper exported for notifiers.
func TestIsValidEmail(t *testing.T) {
if !IsValidEmail("user@example.com") {
t.Errorf("user@example.com should be valid")
}
if IsValidEmail("not-an-email") {
t.Errorf("not-an-email should be invalid")
}
}
// TestFileIDFor verifies the helper that parses <id>.events.log filenames.
func TestFileIDFor(t *testing.T) {
if id, ok := fileIDFor("12345.events.log"); !ok || id != 12345 {
t.Errorf("12345.events.log -> %d, %v; want 12345, true", id, ok)
}
if _, ok := fileIDFor("not-a-number.events.log"); ok {
t.Errorf("non-numeric should fail")
}
if _, ok := fileIDFor("0.events.log"); ok {
t.Errorf("id=0 should fail (no implicit zero-cart grain)")
}
if _, ok := fileIDFor("garbage.txt"); ok {
t.Errorf("non-event-log suffix should fail")
}
}
+187
View File
@@ -0,0 +1,187 @@
package recovery
import (
"context"
"fmt"
"net/mail"
"os"
"strconv"
"strings"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
// Scanner finds RecoveryCandidates by scanning the per-pod event-log directory.
//
// The model:
// - Each cart pod owns a shard of `<id>.events.log` files under `dir`.
// - File mtime approximates "last successful mutation" cheaply — replay only
// when something lands in the window.
// - For each candidate file, replay the grain (a few kb, ms-scale) and build
// a RecoveryCandidate iff it has items + a contact.
//
// Two important implementation details:
//
// 1. After LoadEvents we MUST call UpdateTotals(). Replaying event handlers
// runs the per-mutation registered handlers (SetUserId, SetRecoveryContact,
// AddItem, ...) but NOT the post-mutation processors — those run only on
// the live mutation path (see cmd/cart/main.go::RegisterProcessor for the
// "Totals and promotions" pipeline). Without this call, g.TotalPrice is
// nil and TotalIncVat in the candidate is always 0.
//
// 2. Read-vs-write races: DiskStorage may be in the middle of appending while
// Scan reads. The bufio scanner will fail JSON-unmarshal on a torn line and
// we silently continue — LogEvents treats that as "skip", here we do too.
// A persistent unreadable file is a separate problem runPurge handles.
//
// v0 deliberately does NOT track "already notified" within this scanner —
// the window sizing plus caller conventions give roughly-once delivery.
// Idempotency is the next pass.
type Scanner struct {
dir string
storage *actor.DiskStorage[cart.CartGrain]
}
// NewScanner returns a Scanner over dir (the same CART_DIR used by the cart
// service). storage must share the same dir so LoadEvents reading paths
// match the scanning paths.
func NewScanner(dir string, storage *actor.DiskStorage[cart.CartGrain]) *Scanner {
return &Scanner{dir: dir, storage: storage}
}
// Scan returns candidates whose event-log mtime falls in
// [now-delay-window, now-delay] AND whose grain has items AND (email OR
// push tokens). The half-open window semantics match the caller's intent:
//
// "carts whose mtime is in this slice of the recent past"
//
// — younger than now-delay (still active) and older than now-delay-window
// (never recovery-eligible yet).
//
// Empty slice + nil error means "no candidates this pass"; an error means the
// pass itself failed and the caller should log + retry.
func (s *Scanner) Scan(ctx context.Context, delay, window time.Duration, now time.Time) ([]RecoveryCandidate, error) {
if s == nil || s.storage == nil || s.dir == "" {
return nil, fmt.Errorf("recovery: scanner not configured")
}
if delay <= 0 {
return nil, nil
}
if window <= 0 {
window = delay
}
lower := now.Add(-(delay + window))
upper := now.Add(-delay)
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, fmt.Errorf("recovery: read dir %s: %w", s.dir, err)
}
out := make([]RecoveryCandidate, 0)
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(name, ".events.log") {
continue
}
id, ok := fileIDFor(name)
if !ok {
continue
}
info, infoErr := e.Info()
if infoErr != nil {
continue
}
mtime := info.ModTime()
// Closed window:
// mtime BEFORE lower → never reached the abandonment threshold yet.
// mtime AFTER upper → still too fresh (caller is actively using it).
if mtime.Before(lower) || mtime.After(upper) {
continue
}
grain := cart.NewCartGrain(id, mtime)
if loadErr := s.storage.LoadEvents(ctx, id, grain); loadErr != nil {
// Skip unreadable grains — they're purged separately by runPurge.
continue
}
// Replay does NOT run the Totals-and-promotions processor (see the
// package doc); recompute TotalPrice from the item list so the
// candidate TotalIncVat matches what the live API would return.
grain.UpdateTotals()
if !eligible(grain) {
continue
}
out = append(out, buildCandidate(id, grain, mtime))
}
return out, nil
}
// eligible mirrors "would we ever want to send a reminder?" — items present
// and a contact available. The grain's own UpdateTotals has already been
// run by every mutation's processor, so TotalPrice is reliable here.
//
// Wishlists / offers are excluded — a wishlist cart isn't forgotten, and an
// offer cart has its own delivery semantics.
func eligible(g *cart.CartGrain) bool {
if g == nil || len(g.Items) == 0 {
return false
}
switch g.Type {
case cart_messages.CartType_WISHLIST, cart_messages.CartType_OFFER:
return false
}
hasContact := strings.TrimSpace(g.Email) != "" || len(g.PushTokens) > 0
return hasContact
}
// buildCandidate projects the replayed grain into a RecoveryCandidate. Email
// validation is intentionally lenient — we hand the value to the notifier
// and the notifier decides what to send; rejecting "looks-invalid" addresses
// here would just hide useful debugging signal from the dry-run logs.
func buildCandidate(id uint64, g *cart.CartGrain, mtime time.Time) RecoveryCandidate {
var total int64
if g.TotalPrice != nil {
// tax.Price.IncVat is int64-backed via money.Cents. Reading the field
// directly keeps the recovery layer decoupled from platform/money
// internals.
total = int64(g.TotalPrice.IncVat)
}
tokens := make([]cart.PushToken, len(g.PushTokens))
copy(tokens, g.PushTokens)
return RecoveryCandidate{
CartID: cart.CartId(id),
UserID: g.UserId,
Email: g.Email,
PushTokens: tokens,
ItemCount: len(g.Items),
TotalIncVat: total,
Currency: g.Currency,
LastChangeUnix: mtime.Unix(),
}
}
// IsValidEmail is exported as a convenience the notifier may use to decide
// whether to actually send (MailerSend will reject invalid); the scanner
// itself is permissive.
func IsValidEmail(s string) bool {
_, err := mail.ParseAddress(s)
return err == nil
}
// fileIDFor is a small helper that extracts the grain id from an .events.log
// filename. Returns 0/false if the name doesn't match the expected shape.
func fileIDFor(name string) (uint64, bool) {
base := strings.TrimSuffix(name, ".events.log")
id, err := strconv.ParseUint(base, 10, 64)
if err != nil || id == 0 {
return 0, false
}
return id, true
}
+50
View File
@@ -2,6 +2,7 @@ package discovery
import (
"context"
"log"
"os"
"slices"
"strings"
@@ -11,6 +12,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/cache"
toolsWatch "k8s.io/client-go/tools/watch"
)
@@ -118,3 +120,51 @@ func NewK8sDiscoveryInNamespace(client *kubernetes.Clientset, namespace string,
namespace: namespace,
}
}
// StartK8sDiscovery initializes kubernetes discovery for target.
// If podIp is empty, it returns early.
func StartK8sDiscovery(podIp string, labelSelector string, timeoutSeconds int64, target DiscoveryTarget) {
if podIp == "" {
log.Print("No discovery service available")
return
}
config, kerr := rest.InClusterConfig()
if kerr != nil {
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
}
client, err := kubernetes.NewForConfig(config)
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
hw := NewK8sDiscoveryInNamespace(client, InClusterNamespace(), metav1.ListOptions{
LabelSelector: labelSelector,
TimeoutSeconds: &timeoutSeconds,
})
go func() {
ch, err := hw.Watch()
if err != nil {
log.Printf("Discovery error: %v", err)
return
}
for evt := range ch {
if evt.Host == "" {
continue
}
switch evt.IsReady {
case false:
if target.IsKnown(evt.Host) {
log.Printf("Host %s is not ready, removing", evt.Host)
target.RemoveHost(evt.Host)
}
default:
if !target.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
target.AddRemoteHost(evt.Host)
}
}
}
}()
}
+73
View File
@@ -54,42 +54,78 @@ type HookInfo struct {
// — so observability/notification hooks can't break the business transaction.
type Hook func(ctx context.Context, st *State, info HookInfo, params json.RawMessage) error
// CapabilityMeta is the editor-facing documentation attached at registration
// time. The server generates `/sagas/capabilities` from these registrations, so
// UIs should consume this payload instead of hardcoding per-tool help.
//
// Register every action/predicate/hook with the matching *WithMeta helper when
// it should be discoverable in editors. ExampleParams is arbitrary JSON shown as
// a starter payload for params-capable tools.
type CapabilityMeta struct {
Description string `json:"description,omitempty"`
ExampleParams json.RawMessage `json:"exampleParams,omitempty"`
}
// Registry holds the named actions, predicates and hooks a flow can reference.
type Registry struct {
mu sync.RWMutex
actions map[string]Action
actionMeta map[string]CapabilityMeta
predicates map[string]Predicate
predicateMeta map[string]CapabilityMeta
hooks map[string]Hook
hookMeta map[string]CapabilityMeta
}
// NewRegistry returns an empty registry.
func NewRegistry() *Registry {
return &Registry{
actions: map[string]Action{},
actionMeta: map[string]CapabilityMeta{},
predicates: map[string]Predicate{},
predicateMeta: map[string]CapabilityMeta{},
hooks: map[string]Hook{},
hookMeta: map[string]CapabilityMeta{},
}
}
// Action registers an action under name (overwrites an existing one).
func (r *Registry) Action(name string, fn Action) {
r.ActionWithMeta(name, fn, CapabilityMeta{})
}
// ActionWithMeta registers an action plus the metadata editors should display.
func (r *Registry) ActionWithMeta(name string, fn Action, meta CapabilityMeta) {
r.mu.Lock()
defer r.mu.Unlock()
r.actions[name] = fn
r.actionMeta[name] = meta
}
// Predicate registers a predicate under name.
func (r *Registry) Predicate(name string, fn Predicate) {
r.PredicateWithMeta(name, fn, CapabilityMeta{})
}
// PredicateWithMeta registers a predicate plus the metadata editors should display.
func (r *Registry) PredicateWithMeta(name string, fn Predicate, meta CapabilityMeta) {
r.mu.Lock()
defer r.mu.Unlock()
r.predicates[name] = fn
r.predicateMeta[name] = meta
}
// Hook registers a hook under name.
func (r *Registry) Hook(name string, fn Hook) {
r.HookWithMeta(name, fn, CapabilityMeta{})
}
// HookWithMeta registers a hook plus the metadata editors should display.
func (r *Registry) HookWithMeta(name string, fn Hook, meta CapabilityMeta) {
r.mu.Lock()
defer r.mu.Unlock()
r.hooks[name] = fn
r.hookMeta[name] = meta
}
func (r *Registry) action(name string) (Action, bool) {
@@ -117,8 +153,11 @@ func (r *Registry) hook(name string) (Hook, bool) {
// offer exactly the actions/predicates/hooks a flow may reference.
type Capabilities struct {
Actions []string `json:"actions"`
ActionMeta map[string]CapabilityMeta `json:"actionMeta,omitempty"`
Predicates []string `json:"predicates"`
PredicateMeta map[string]CapabilityMeta `json:"predicateMeta,omitempty"`
Hooks []string `json:"hooks"`
HookMeta map[string]CapabilityMeta `json:"hookMeta,omitempty"`
}
// Capabilities returns the sorted registered names.
@@ -127,11 +166,22 @@ func (r *Registry) Capabilities() Capabilities {
defer r.mu.RUnlock()
return Capabilities{
Actions: sortedKeys(r.actions),
ActionMeta: cloneMeta(r.actionMeta),
Predicates: sortedKeys(r.predicates),
PredicateMeta: cloneMeta(r.predicateMeta),
Hooks: sortedKeys(r.hooks),
HookMeta: cloneMeta(r.hookMeta),
}
}
func cloneMeta(in map[string]CapabilityMeta) map[string]CapabilityMeta {
out := make(map[string]CapabilityMeta, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
@@ -192,6 +242,11 @@ func (e *Engine) Validate(def *Definition) error {
if _, ok := e.reg.hook(h.Type); !ok {
return fmt.Errorf("flow %q step %q: unknown hook %q", def.Name, s.Name, h.Type)
}
if h.When != "" {
if _, ok := e.reg.predicate(h.When); !ok {
return fmt.Errorf("flow %q step %q: hook %q: unknown predicate %q", def.Name, s.Name, h.Type, h.When)
}
}
}
}
}
@@ -287,8 +342,26 @@ func (e *Engine) compensate(ctx context.Context, res *Result, executed []Step, s
}
// fireHooks runs every hook for a phase, logging (never propagating) failures.
// Hooks with a When predicate are evaluated first; the hook is skipped when the
// predicate returns false.
func (e *Engine) fireHooks(ctx context.Context, st *State, refs []HookRef, info HookInfo) {
for _, ref := range refs {
if ref.When != "" {
pred, ok := e.reg.predicate(ref.When)
if !ok {
st.Logger.Error("flow hook skipped: unknown predicate", "hook", ref.Type, "predicate", ref.When, "step", info.Step)
continue
}
run, err := pred(ctx, st)
if err != nil {
st.Logger.Error("flow hook predicate error", "hook", ref.Type, "predicate", ref.When, "step", info.Step, "err", err)
continue
}
if !run {
st.Logger.Debug("flow hook skipped by predicate", "hook", ref.Type, "predicate", ref.When, "step", info.Step)
continue
}
}
h, ok := e.reg.hook(ref.Type)
if !ok {
st.Logger.Error("flow hook skipped: unknown type", "hook", ref.Type, "step", info.Step)
+3 -1
View File
@@ -51,9 +51,11 @@ type Hooks struct {
OnError []HookRef `json:"onError,omitempty"`
}
// HookRef names a registered hook and its params.
// HookRef names a registered hook and its params. When, if set, names a
// registered predicate; the hook is skipped when it returns false.
type HookRef struct {
Type string `json:"type"`
When string `json:"when,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
}
+11 -3
View File
@@ -13,9 +13,17 @@ import (
// (structured log line) and "webhook" (HTTP POST of the event). Domain packages
// register their own hooks (e.g. "amqp_emit") on the same registry.
func RegisterBuiltinHooks(reg *Registry) {
reg.Hook("log", logHook)
reg.Hook("noop", func(context.Context, *State, HookInfo, json.RawMessage) error { return nil })
reg.Hook("webhook", webhookHook(&http.Client{Timeout: 10 * time.Second}))
reg.HookWithMeta("log", logHook, CapabilityMeta{
Description: "Write a structured log line for the current step and phase.",
ExampleParams: json.RawMessage(`{"message":"payment captured"}`),
})
reg.HookWithMeta("noop", func(context.Context, *State, HookInfo, json.RawMessage) error { return nil }, CapabilityMeta{
Description: "Do nothing. Useful as a placeholder while shaping a flow.",
})
reg.HookWithMeta("webhook", webhookHook(&http.Client{Timeout: 10 * time.Second}), CapabilityMeta{
Description: "POST the flow event payload to an external HTTP endpoint.",
ExampleParams: json.RawMessage(`{"url":"https://example.test/order-events","headers":{"X-Flow":"place-and-pay"}}`),
})
}
// logHook emits a structured log line for the step/phase. Params (optional):
+114 -23
View File
@@ -63,8 +63,8 @@ const PlaceOrderVar = "placeOrder"
// RegisterFlowActions registers the order lifecycle actions on reg, driving the
// grain through app and taking payment via provider. Compose with
// flow.RegisterBuiltinHooks(reg) to get the "log"/"webhook" hooks too.
func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvider) {
reg.Action("place_order", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvider, prefCheckers ...EmailPreferenceChecker) {
reg.ActionWithMeta("place_order", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
po, ok := st.Vars[PlaceOrderVar].(*messages.PlaceOrder)
if !ok || po == nil {
return fmt.Errorf("place_order: flow var %q is not a *PlaceOrder", PlaceOrderVar)
@@ -78,9 +78,9 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
st.Vars["currency"] = po.GetCurrency()
st.Vars["orderReference"] = po.GetOrderReference()
return nil
})
}, flow.CapabilityMeta{Description: "Create the order from the flow state variable `placeOrder`."})
reg.Action("authorize_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
reg.ActionWithMeta("authorize_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
o, err := app.Get(ctx, st.ID)
if err != nil {
return err
@@ -101,9 +101,9 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
st.Vars["authProvider"] = auth.Provider
st.Vars["authAmount"] = auth.Amount
return nil
})
}, flow.CapabilityMeta{Description: "Authorize payment with the configured provider."})
reg.Action("capture_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
reg.ActionWithMeta("capture_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
authRef, _ := st.Vars["authRef"].(string)
amount, _ := st.Vars["authAmount"].(int64)
if amount == 0 {
@@ -121,11 +121,11 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
Reference: capture.Reference,
AtMs: nowMs(),
})
})
}, flow.CapabilityMeta{Description: "Capture the authorized payment."})
// void_payment is the compensation for authorize_payment: void with the
// provider and cancel the order if it is still in a cancellable state.
reg.Action("void_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
reg.ActionWithMeta("void_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
if authRef, ok := st.Vars["authRef"].(string); ok && authRef != "" {
if err := provider.Void(ctx, authRef); err != nil {
return err
@@ -134,9 +134,9 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
// Best-effort cancel; ignore if no longer cancellable.
_, _ = app.Apply(ctx, st.ID, &messages.CancelOrder{Reason: "compensation: payment voided", AtMs: nowMs()})
return nil
})
}, flow.CapabilityMeta{Description: "Void the authorization and cancel the order as compensation."})
reg.Action("cancel_order", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
reg.ActionWithMeta("cancel_order", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
reason := "cancelled"
if len(params) > 0 {
var p struct {
@@ -147,9 +147,12 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
}
}
return applyOne(ctx, app, st.ID, &messages.CancelOrder{Reason: reason, AtMs: nowMs()})
}, flow.CapabilityMeta{
Description: "Cancel the order with an optional reason.",
ExampleParams: json.RawMessage(`{"reason":"customer requested cancellation"}`),
})
reg.Action("issue_refund", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
reg.ActionWithMeta("issue_refund", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
o, err := app.Get(ctx, st.ID)
if err != nil {
return err
@@ -180,9 +183,71 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
Reference: ref.Reference,
AtMs: nowMs(),
})
}, flow.CapabilityMeta{
Description: "Refund the remaining or specified captured amount.",
ExampleParams: json.RawMessage(`{"amount":2500}`),
})
registerPredicates(reg, app)
reg.ActionWithMeta("create_fulfillment", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
var p struct {
ID string `json:"id"`
Carrier string `json:"carrier,omitempty"`
TrackingNumber string `json:"trackingNumber,omitempty"`
TrackingURI string `json:"trackingUri,omitempty"`
AtMs int64 `json:"atMs,omitempty"`
Lines []struct {
Reference string `json:"reference"`
Quantity int32 `json:"quantity"`
} `json:"lines"`
}
if err := json.Unmarshal(params, &p); err != nil {
return fmt.Errorf("create_fulfillment: bad params: %w", err)
}
if len(p.Lines) == 0 {
return fmt.Errorf("create_fulfillment: missing lines")
}
id := p.ID
if id == "" {
fid, err := NewOrderId()
if err != nil {
return err
}
id = "f_" + fid.String()
}
atMs := p.AtMs
if atMs == 0 {
atMs = nowMs()
}
msg := &messages.CreateFulfillment{
Id: id,
Carrier: p.Carrier,
TrackingNumber: p.TrackingNumber,
TrackingUri: p.TrackingURI,
AtMs: atMs,
}
for _, line := range p.Lines {
if line.Reference == "" {
return fmt.Errorf("create_fulfillment: line missing reference")
}
if line.Quantity <= 0 {
return fmt.Errorf("create_fulfillment: line %q has invalid quantity %d", line.Reference, line.Quantity)
}
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{
Reference: line.Reference,
Quantity: line.Quantity,
})
}
return applyOne(ctx, app, st.ID, msg)
}, flow.CapabilityMeta{
Description: "Create a shipment / fulfillment and advance the order fulfillment status.",
ExampleParams: json.RawMessage(`{"carrier":"dropshipper","trackingNumber":"TRACK-123","trackingUri":"https://tracking.example/TRACK-123","lines":[{"reference":"l1","quantity":1}]}`),
})
var checker EmailPreferenceChecker
if len(prefCheckers) > 0 {
checker = prefCheckers[0]
}
registerPredicates(reg, app, checker)
}
// registerPredicates adds the order gating predicates a step can reference via
@@ -190,42 +255,68 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
// at the moment the step is reached (e.g. only fire a receipt hook once the
// order is captured). Predicates take no params (the engine's When is a bare
// name), so each is a fixed, composable boolean.
func registerPredicates(reg *flow.Registry, app Applier) {
func registerPredicates(reg *flow.Registry, app Applier, prefChecker EmailPreferenceChecker) {
get := func(ctx context.Context, st *flow.State) (*OrderGrain, error) { return app.Get(ctx, st.ID) }
reg.Predicate("has_customer_email", func(ctx context.Context, st *flow.State) (bool, error) {
reg.PredicateWithMeta("has_customer_email", func(ctx context.Context, st *flow.State) (bool, error) {
o, err := get(ctx, st)
if err != nil {
return false, err
}
return o.CustomerEmail != "", nil
})
reg.Predicate("has_items", func(ctx context.Context, st *flow.State) (bool, error) {
}, flow.CapabilityMeta{Description: "Run only when the order has a customer email address."})
reg.PredicateWithMeta("has_items", func(ctx context.Context, st *flow.State) (bool, error) {
o, err := get(ctx, st)
if err != nil {
return false, err
}
return len(o.Lines) > 0, nil
})
reg.Predicate("is_captured", func(ctx context.Context, st *flow.State) (bool, error) {
}, flow.CapabilityMeta{Description: "Run only when the order contains at least one line item."})
reg.PredicateWithMeta("is_captured", func(ctx context.Context, st *flow.State) (bool, error) {
o, err := get(ctx, st)
if err != nil {
return false, err
}
return o.Status == StatusCaptured, nil
})
reg.Predicate("not_captured", func(ctx context.Context, st *flow.State) (bool, error) {
}, flow.CapabilityMeta{Description: "Run only after payment has been captured."})
reg.PredicateWithMeta("not_captured", func(ctx context.Context, st *flow.State) (bool, error) {
o, err := get(ctx, st)
if err != nil {
return false, err
}
return o.Status != StatusCaptured, nil
})
reg.Predicate("has_open_balance", func(ctx context.Context, st *flow.State) (bool, error) {
}, flow.CapabilityMeta{Description: "Run only before payment is captured."})
reg.PredicateWithMeta("has_open_balance", func(ctx context.Context, st *flow.State) (bool, error) {
o, err := get(ctx, st)
if err != nil {
return false, err
}
return o.CapturedAmount-o.RefundedAmount > 0, nil
})
}, flow.CapabilityMeta{Description: "Run only when captured amount remains to refund."})
// Email consent predicates — gate marketing or order steps when the customer
// has opted out in their profile email preferences.
if prefChecker != nil {
reg.PredicateWithMeta("has_marketing_consent", func(ctx context.Context, st *flow.State) (bool, error) {
o, err := get(ctx, st)
if err != nil {
return false, err
}
if o.CustomerEmail == "" {
return false, nil // no email address -> no consent
}
return prefChecker.Allowed(ctx, o.CustomerEmail, "marketing"), nil
}, flow.CapabilityMeta{Description: "Run only when the customer has consented to marketing emails."})
reg.PredicateWithMeta("has_order_email_consent", func(ctx context.Context, st *flow.State) (bool, error) {
o, err := get(ctx, st)
if err != nil {
return false, err
}
if o.CustomerEmail == "" {
return false, nil // no email address -> cannot receive order emails
}
return prefChecker.Allowed(ctx, o.CustomerEmail, "order"), nil
}, flow.CapabilityMeta{Description: "Run only when the customer has consented to order transactional emails."})
}
}
+241 -1
View File
@@ -3,19 +3,28 @@ package order
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/mail"
"slices"
"strings"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
)
func fullRegistry(t *testing.T, pub Publisher) *flow.Registry {
t.Helper()
pool := newPool(t)
reg := flow.NewRegistry()
flow.RegisterBuiltinHooks(reg)
RegisterFlowActions(reg, newPool(t), NewMockProvider())
RegisterFlowActions(reg, pool, NewMockProvider())
RegisterInventoryReservationActions(reg, pool, nil)
RegisterEmitHook(reg, pub)
RegisterEmailHook(reg, pool, nil, nil)
RegisterFulfillmentWebhookHook(reg, pool, nil)
return reg
}
@@ -30,6 +39,92 @@ func TestCapabilitiesIncludesPredicatesAndEmit(t *testing.T) {
if !slices.Contains(caps.Hooks, "amqp_emit") {
t.Errorf("hooks missing amqp_emit (got %v)", caps.Hooks)
}
if !slices.Contains(caps.Hooks, "send_email") {
t.Errorf("hooks missing send_email (got %v)", caps.Hooks)
}
if !slices.Contains(caps.Hooks, "fulfillment_webhook") {
t.Errorf("hooks missing fulfillment_webhook (got %v)", caps.Hooks)
}
if !slices.Contains(caps.Actions, "create_fulfillment") {
t.Errorf("actions missing create_fulfillment (got %v)", caps.Actions)
}
if !slices.Contains(caps.Actions, "reserve_inventory") {
t.Errorf("actions missing reserve_inventory (got %v)", caps.Actions)
}
if !slices.Contains(caps.Actions, "release_inventory") {
t.Errorf("actions missing release_inventory (got %v)", caps.Actions)
}
if caps.ActionMeta["create_fulfillment"].Description == "" {
t.Fatalf("action meta missing for create_fulfillment: %#v", caps.ActionMeta["create_fulfillment"])
}
if len(caps.HookMeta["fulfillment_webhook"].ExampleParams) == 0 {
t.Fatalf("hook meta missing example params for fulfillment_webhook: %#v", caps.HookMeta["fulfillment_webhook"])
}
if caps.PredicateMeta["has_customer_email"].Description == "" {
t.Fatalf("predicate meta missing for has_customer_email: %#v", caps.PredicateMeta["has_customer_email"])
}
}
type fakeInventoryReservationService struct {
reserved []InventoryReservationRequest
released []InventoryReservationRequest
}
func (f *fakeInventoryReservationService) Reserve(_ context.Context, req InventoryReservationRequest) error {
f.reserved = append(f.reserved, req)
return nil
}
func (f *fakeInventoryReservationService) Release(_ context.Context, req InventoryReservationRequest) error {
f.released = append(f.released, req)
return nil
}
func TestReserveInventoryActionBuildsReservationRequest(t *testing.T) {
pool := newPool(t)
reg := flow.NewRegistry()
RegisterFlowActions(reg, pool, NewMockProvider())
fake := &fakeInventoryReservationService{}
RegisterInventoryReservationActions(reg, pool, fake)
eng := flow.NewEngine(reg, nil)
const id = 703
st := flow.NewState(id, nil)
po := placeMsg()
st.Vars[PlaceOrderVar] = po
def, err := EmbeddedFlow("place-and-pay")
if err != nil {
t.Fatal(err)
}
if _, err := eng.Run(context.Background(), def, st); err != nil {
t.Fatal(err)
}
reserve := &flow.Definition{Name: "reserve", Steps: []flow.Step{{
Name: "reserve",
Action: "reserve_inventory",
Params: json.RawMessage(`{"ttlSeconds":600,"lines":[{"reference":"l1","quantity":1,"locationId":"se"}]}`),
Compensate: &flow.ActionRef{
Action: "release_inventory",
Params: json.RawMessage(`{"lines":[{"reference":"l1","quantity":1,"locationId":"se"}]}`),
},
}}}
if _, err := eng.Run(context.Background(), reserve, flow.NewState(id, nil)); err != nil {
t.Fatal(err)
}
if len(fake.reserved) != 1 {
t.Fatalf("reserved = %d, want 1", len(fake.reserved))
}
got := fake.reserved[0]
if got.HolderID != "order:"+OrderId(id).String() {
t.Fatalf("holderId = %q", got.HolderID)
}
if got.TTL != 10*time.Minute {
t.Fatalf("ttl = %s", got.TTL)
}
if len(got.Lines) != 1 || got.Lines[0].SKU != "ABC" || got.Lines[0].Quantity != 1 {
t.Fatalf("lines = %+v", got.Lines)
}
}
func TestIsCapturedPredicateGatesStep(t *testing.T) {
@@ -94,3 +189,148 @@ func TestAmqpEmitHookPublishes(t *testing.T) {
t.Fatalf("expected one message routed to k, got %v", fp.msgs)
}
}
type fakeEmailSender struct {
defaultFrom mail.Address
msgs []EmailMessage
}
func (f *fakeEmailSender) DefaultFrom() mail.Address { return f.defaultFrom }
func (f *fakeEmailSender) Send(_ context.Context, msg EmailMessage) error {
f.msgs = append(f.msgs, msg)
return nil
}
func TestSendEmailHookRendersFulfillmentTemplate(t *testing.T) {
pool := newPool(t)
reg := flow.NewRegistry()
RegisterFlowActions(reg, pool, NewMockProvider())
sender := &fakeEmailSender{defaultFrom: mail.Address{Name: "Ops", Address: "ops@example.com"}}
templates := newEmailTemplateCatalog(map[string]EmailTemplate{
"ship": {
Subject: "Order {{ .Order.OrderReference }} shipped",
Text: "Tracking {{ .Fulfillment.TrackingNumber }} for {{ .Order.CustomerEmail }}",
},
})
RegisterEmailHook(reg, pool, sender, templates)
eng := flow.NewEngine(reg, nil)
const id = 701
st := flow.NewState(id, nil)
po := placeMsg()
po.CustomerEmail = "alice@example.com"
po.CustomerName = "Alice"
st.Vars[PlaceOrderVar] = po
def, err := EmbeddedFlow("place-and-pay")
if err != nil {
t.Fatal(err)
}
if _, err := eng.Run(context.Background(), def, st); err != nil {
t.Fatal(err)
}
ship := &flow.Definition{Name: "ship", Steps: []flow.Step{{
Name: "fulfill",
Action: "create_fulfillment",
Params: json.RawMessage(`{"carrier":"postnord","trackingNumber":"TRACK-123","trackingUri":"https://track.example/TRACK-123","lines":[{"reference":"l1","quantity":1}]}`),
Hooks: flow.Hooks{After: []flow.HookRef{{Type: "send_email", Params: json.RawMessage(`{"template":"ship"}`)}}},
}}}
if _, err := eng.Run(context.Background(), ship, flow.NewState(id, nil)); err != nil {
t.Fatal(err)
}
if len(sender.msgs) != 1 {
t.Fatalf("sent %d emails, want 1", len(sender.msgs))
}
got := sender.msgs[0]
if got.From.Address != "ops@example.com" {
t.Fatalf("from = %q, want ops@example.com", got.From.Address)
}
if len(got.To) != 1 || got.To[0].Address != "alice@example.com" {
t.Fatalf("to = %+v, want alice@example.com", got.To)
}
if got.Subject != "Order ref-1 shipped" {
t.Fatalf("subject = %q", got.Subject)
}
if !strings.Contains(got.TextBody, "TRACK-123") {
t.Fatalf("text body = %q, want tracking number", got.TextBody)
}
}
func TestFulfillmentWebhookPostsOrderPayload(t *testing.T) {
pool := newPool(t)
reg := flow.NewRegistry()
RegisterFlowActions(reg, pool, NewMockProvider())
var gotMethod, gotPath, gotHeader string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotHeader = r.Header.Get("X-Integration")
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode webhook body: %v", err)
}
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
RegisterFulfillmentWebhookHook(reg, pool, srv.Client())
eng := flow.NewEngine(reg, nil)
const id = 702
st := flow.NewState(id, nil)
po := placeMsg()
po.CustomerEmail = "dropship@example.com"
st.Vars[PlaceOrderVar] = po
def, err := EmbeddedFlow("place-and-pay")
if err != nil {
t.Fatal(err)
}
if _, err := eng.Run(context.Background(), def, st); err != nil {
t.Fatal(err)
}
ship := &flow.Definition{Name: "ship", Steps: []flow.Step{{
Name: "fulfill",
Action: "create_fulfillment",
Params: json.RawMessage(`{"carrier":"dropshipper","trackingNumber":"DS-123","lines":[{"reference":"l1","quantity":1}]}`),
Hooks: flow.Hooks{After: []flow.HookRef{{
Type: "fulfillment_webhook",
Params: json.RawMessage(fmt.Sprintf(`{
"url": %q,
"method": "POST",
"headers": {
"X-Integration": "dropship {{ .Order.OrderReference }}"
}
}`, srv.URL+`/hooks/{{ .OrderID }}`)),
}}},
}}}
if _, err := eng.Run(context.Background(), ship, flow.NewState(id, nil)); err != nil {
t.Fatal(err)
}
if gotMethod != http.MethodPost {
t.Fatalf("method = %q, want POST", gotMethod)
}
if gotPath != "/hooks/"+OrderId(id).String() {
t.Fatalf("path = %q", gotPath)
}
if gotHeader != "dropship ref-1" {
t.Fatalf("header = %q", gotHeader)
}
if gotBody["orderId"] != OrderId(id).String() {
t.Fatalf("body orderId = %v", gotBody["orderId"])
}
fulfillment, ok := gotBody["fulfillment"].(map[string]any)
if !ok {
t.Fatalf("body fulfillment missing: %#v", gotBody["fulfillment"])
}
if fulfillment["trackingNumber"] != "DS-123" {
t.Fatalf("trackingNumber = %v", fulfillment["trackingNumber"])
}
}
+592
View File
@@ -0,0 +1,592 @@
package order
import (
"bytes"
"context"
"crypto/tls"
"embed"
"encoding/json"
"fmt"
htmltmpl "html/template"
"io/fs"
"mime"
"mime/quotedprintable"
"net"
"net/mail"
"net/smtp"
"os"
"path/filepath"
"strings"
texttmpl "text/template"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/platform/money"
)
// EmailMessage is the rendered mail payload a sender delivers.
type EmailMessage struct {
From mail.Address
To []mail.Address
Subject string
TextBody string
HTMLBody string
}
// EmailSender delivers an already-rendered mail and exposes its default sender.
type EmailSender interface {
Send(ctx context.Context, msg EmailMessage) error
DefaultFrom() mail.Address
}
// SMTPEmailSenderConfig configures the SMTP mail transport.
type SMTPEmailSenderConfig struct {
Host string
Port int
Username string
Password string
DefaultFrom mail.Address
Timeout time.Duration
}
type smtpEmailSender struct {
host string
addr string
auth smtp.Auth
defaultFrom mail.Address
timeout time.Duration
}
// NewSMTPEmailSender returns an SMTP-backed sender. It supports plain SMTP with
// opportunistic STARTTLS, which is the only configured transport for now.
func NewSMTPEmailSender(cfg SMTPEmailSenderConfig) (EmailSender, error) {
if strings.TrimSpace(cfg.Host) == "" {
return nil, fmt.Errorf("smtp sender: missing host")
}
if cfg.Port <= 0 {
return nil, fmt.Errorf("smtp sender: invalid port %d", cfg.Port)
}
if cfg.Timeout <= 0 {
cfg.Timeout = 10 * time.Second
}
if strings.TrimSpace(cfg.DefaultFrom.Address) == "" {
return nil, fmt.Errorf("smtp sender: missing default from address")
}
s := &smtpEmailSender{
host: cfg.Host,
addr: net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port)),
defaultFrom: cfg.DefaultFrom,
timeout: cfg.Timeout,
}
if cfg.Username != "" {
s.auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
}
return s, nil
}
func (s *smtpEmailSender) DefaultFrom() mail.Address { return s.defaultFrom }
func (s *smtpEmailSender) Send(ctx context.Context, msg EmailMessage) error {
if strings.TrimSpace(msg.From.Address) == "" {
return fmt.Errorf("smtp sender: missing from address")
}
if len(msg.To) == 0 {
return fmt.Errorf("smtp sender: missing recipients")
}
if strings.TrimSpace(msg.Subject) == "" {
return fmt.Errorf("smtp sender: missing subject")
}
body, err := buildSMTPMessage(msg)
if err != nil {
return err
}
conn, err := (&net.Dialer{Timeout: s.timeout}).DialContext(ctx, "tcp", s.addr)
if err != nil {
return fmt.Errorf("smtp sender: dial %s: %w", s.addr, err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(s.timeout))
client, err := smtp.NewClient(conn, s.host)
if err != nil {
return fmt.Errorf("smtp sender: create client: %w", err)
}
defer client.Close()
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: s.host, MinVersion: tls.VersionTLS12}); err != nil {
return fmt.Errorf("smtp sender: starttls: %w", err)
}
}
if s.auth != nil {
if ok, _ := client.Extension("AUTH"); !ok {
return fmt.Errorf("smtp sender: server does not support auth")
}
if err := client.Auth(s.auth); err != nil {
return fmt.Errorf("smtp sender: auth: %w", err)
}
}
if err := client.Mail(msg.From.Address); err != nil {
return fmt.Errorf("smtp sender: mail from %s: %w", msg.From.Address, err)
}
for _, rcpt := range msg.To {
if err := client.Rcpt(rcpt.Address); err != nil {
return fmt.Errorf("smtp sender: rcpt to %s: %w", rcpt.Address, err)
}
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp sender: data: %w", err)
}
if _, err := w.Write(body); err != nil {
_ = w.Close()
return fmt.Errorf("smtp sender: write body: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp sender: close body: %w", err)
}
if err := client.Quit(); err != nil {
return fmt.Errorf("smtp sender: quit: %w", err)
}
return nil
}
func buildSMTPMessage(msg EmailMessage) ([]byte, error) {
if strings.TrimSpace(msg.TextBody) == "" && strings.TrimSpace(msg.HTMLBody) == "" {
return nil, fmt.Errorf("email: missing body")
}
var buf bytes.Buffer
writeHeader := func(name, value string) {
buf.WriteString(name)
buf.WriteString(": ")
buf.WriteString(value)
buf.WriteString("\r\n")
}
writeHeader("From", msg.From.String())
toList := make([]string, 0, len(msg.To))
for _, rcpt := range msg.To {
toList = append(toList, rcpt.String())
}
writeHeader("To", strings.Join(toList, ", "))
writeHeader("Subject", mime.QEncoding.Encode("utf-8", msg.Subject))
writeHeader("Date", time.Now().Format(time.RFC1123Z))
writeHeader("MIME-Version", "1.0")
switch {
case msg.TextBody != "" && msg.HTMLBody != "":
boundary := fmt.Sprintf("alt-%d", time.Now().UnixNano())
writeHeader("Content-Type", fmt.Sprintf(`multipart/alternative; boundary="%s"`, boundary))
buf.WriteString("\r\n")
writeMIMEPart(&buf, boundary, "text/plain; charset=utf-8", msg.TextBody)
writeMIMEPart(&buf, boundary, "text/html; charset=utf-8", msg.HTMLBody)
buf.WriteString("--")
buf.WriteString(boundary)
buf.WriteString("--\r\n")
case msg.HTMLBody != "":
writeHeader("Content-Type", `text/html; charset=utf-8`)
writeHeader("Content-Transfer-Encoding", "quoted-printable")
buf.WriteString("\r\n")
if err := writeQuotedPrintable(&buf, msg.HTMLBody); err != nil {
return nil, fmt.Errorf("email: encode html body: %w", err)
}
default:
writeHeader("Content-Type", `text/plain; charset=utf-8`)
writeHeader("Content-Transfer-Encoding", "quoted-printable")
buf.WriteString("\r\n")
if err := writeQuotedPrintable(&buf, msg.TextBody); err != nil {
return nil, fmt.Errorf("email: encode text body: %w", err)
}
}
return buf.Bytes(), nil
}
func writeMIMEPart(buf *bytes.Buffer, boundary, contentType, body string) {
buf.WriteString("--")
buf.WriteString(boundary)
buf.WriteString("\r\n")
buf.WriteString("Content-Type: ")
buf.WriteString(contentType)
buf.WriteString("\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
_ = writeQuotedPrintable(buf, body)
buf.WriteString("\r\n")
}
func writeQuotedPrintable(buf *bytes.Buffer, body string) error {
w := quotedprintable.NewWriter(buf)
if _, err := w.Write([]byte(strings.ReplaceAll(body, "\n", "\r\n"))); err != nil {
return err
}
return w.Close()
}
// EmailTemplate is a named mail template, typically selected by a flow hook.
type EmailTemplate struct {
Subject string `json:"subject"`
Text string `json:"text,omitempty"`
HTML string `json:"html,omitempty"`
}
func (t EmailTemplate) validate(name string) error {
return t.Validate(name)
}
// Validate checks that the template has the required fields. Exported so callers
// (e.g. the email template admin API) can validate before persisting.
func (t EmailTemplate) Validate(name string) error {
if strings.TrimSpace(t.Subject) == "" {
return fmt.Errorf("email template %q: missing subject", name)
}
if strings.TrimSpace(t.Text) == "" && strings.TrimSpace(t.HTML) == "" {
return fmt.Errorf("email template %q: missing text/html body", name)
}
return nil
}
// EmailTemplateStore resolves a template by name.
type EmailTemplateStore interface {
Get(name string) (EmailTemplate, error)
}
type emailTemplateCatalog struct {
templates map[string]EmailTemplate
}
func newEmailTemplateCatalog(seed map[string]EmailTemplate) *emailTemplateCatalog {
out := &emailTemplateCatalog{templates: make(map[string]EmailTemplate, len(seed))}
for name, tmpl := range seed {
out.templates[name] = tmpl
}
return out
}
func (c *emailTemplateCatalog) Get(name string) (EmailTemplate, error) {
tmpl, ok := c.templates[name]
if !ok {
return EmailTemplate{}, fmt.Errorf("unknown template %q", name)
}
return tmpl, nil
}
//go:embed email_templates/*.json
var embeddedEmailTemplateFS embed.FS
// LoadEmailTemplates returns the embedded templates overlaid by any JSON files in
// dir (same precedence model as flow definitions: on-disk wins).
func LoadEmailTemplates(dir string) (EmailTemplateStore, error) {
templates := map[string]EmailTemplate{}
matches, err := fs.Glob(embeddedEmailTemplateFS, "email_templates/*.json")
if err != nil {
return nil, fmt.Errorf("list embedded email templates: %w", err)
}
for _, match := range matches {
data, err := embeddedEmailTemplateFS.ReadFile(match)
if err != nil {
return nil, fmt.Errorf("read embedded email template %s: %w", match, err)
}
name := strings.TrimSuffix(filepath.Base(match), ".json")
tmpl, err := parseEmailTemplate(name, data)
if err != nil {
return nil, err
}
templates[name] = tmpl
}
if dir != "" {
if _, err := os.Stat(dir); err == nil {
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
if err != nil {
return nil, fmt.Errorf("list email templates in %s: %w", dir, err)
}
for _, file := range files {
data, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("read email template %s: %w", file, err)
}
name := strings.TrimSuffix(filepath.Base(file), ".json")
tmpl, err := parseEmailTemplate(name, data)
if err != nil {
return nil, err
}
templates[name] = tmpl
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("stat email template dir %s: %w", dir, err)
}
}
return newEmailTemplateCatalog(templates), nil
}
func parseEmailTemplate(name string, data []byte) (EmailTemplate, error) {
var tmpl EmailTemplate
if err := json.Unmarshal(data, &tmpl); err != nil {
return EmailTemplate{}, fmt.Errorf("parse email template %q: %w", name, err)
}
if err := tmpl.validate(name); err != nil {
return EmailTemplate{}, err
}
return tmpl, nil
}
type emailHookParams struct {
Template string `json:"template"`
To string `json:"to,omitempty"`
From string `json:"from,omitempty"`
Subject string `json:"subject,omitempty"`
Category string `json:"category,omitempty"` // "order" or "marketing"; checked against preferences
}
type flowTemplateData struct {
Order *OrderGrain
OrderID string
Step string
Phase flow.Phase
Error string
Vars map[string]any
Fulfillment *Fulfillment
ShippingAddress any
BillingAddress any
}
// EmailPreferenceChecker determines whether an email of a given category should
// be sent to a customer. Returns true when preferences are unknown (allow).
type EmailPreferenceChecker interface {
// Allowed returns true if the customer with the given email has opted in to
// the given category. category is one of "order" or "marketing".
Allowed(ctx context.Context, email string, category string) bool
}
// RegisterEmailHook registers the "send_email" flow hook. It reads the current
// order for template data, renders the named template, and delivers it through
// the configured sender. When checker is non-nil, emails in a category are
// gated by the customer's saved preferences. Hook params:
//
// {
// "template": "fulfillment-shipped",
// "to": "{{ .Order.CustomerEmail }}",
// "from": "Warehouse <warehouse@example.com>",
// "category": "order"
// }
func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, templates EmailTemplateStore, checker ...EmailPreferenceChecker) {
var prefCheck EmailPreferenceChecker
if len(checker) > 0 {
prefCheck = checker[0]
}
reg.HookWithMeta("send_email", func(ctx context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
if sender == nil {
return fmt.Errorf("send_email: no sender configured")
}
if templates == nil {
return fmt.Errorf("send_email: no template store configured")
}
var p emailHookParams
if len(params) > 0 {
if err := json.Unmarshal(params, &p); err != nil {
return fmt.Errorf("send_email: bad params: %w", err)
}
}
if p.Template == "" {
return fmt.Errorf("send_email: missing template")
}
o, err := app.Get(ctx, st.ID)
if err != nil {
return err
}
tmpl, err := templates.Get(p.Template)
if err != nil {
return fmt.Errorf("send_email: %w", err)
}
data := buildFlowTemplateData(o, st, info)
subjectSource := tmpl.Subject
if p.Subject != "" {
subjectSource = p.Subject
}
subject, err := renderTextTemplate("email-subject", subjectSource, data)
if err != nil {
return fmt.Errorf("send_email: render subject: %w", err)
}
textBody, err := renderOptionalTextTemplate("email-text", tmpl.Text, data)
if err != nil {
return fmt.Errorf("send_email: render text body: %w", err)
}
htmlBody, err := renderOptionalHTMLTemplate("email-html", tmpl.HTML, data)
if err != nil {
return fmt.Errorf("send_email: render html body: %w", err)
}
toSource := p.To
if strings.TrimSpace(toSource) == "" {
toSource = o.CustomerEmail
}
toRendered, err := renderTextTemplate("email-to", toSource, data)
if err != nil {
return fmt.Errorf("send_email: render recipient: %w", err)
}
recipients, err := mail.ParseAddressList(toRendered)
if err != nil {
return fmt.Errorf("send_email: parse recipient %q: %w", toRendered, err)
}
to := make([]mail.Address, 0, len(recipients))
for _, rcpt := range recipients {
to = append(to, *rcpt)
}
// Check email preferences before sending.
if prefCheck != nil && p.Category != "" && len(to) > 0 {
email := to[0].Address
if !prefCheck.Allowed(ctx, email, p.Category) {
return nil // silently skip — customer opted out
}
}
from := sender.DefaultFrom()
if strings.TrimSpace(p.From) != "" {
fromRendered, err := renderTextTemplate("email-from", p.From, data)
if err != nil {
return fmt.Errorf("send_email: render sender: %w", err)
}
parsed, err := mail.ParseAddress(strings.TrimSpace(fromRendered))
if err != nil {
return fmt.Errorf("send_email: parse sender %q: %w", fromRendered, err)
}
from = *parsed
}
return sender.Send(ctx, EmailMessage{
From: from,
To: to,
Subject: subject,
TextBody: textBody,
HTMLBody: htmlBody,
})
}, flow.CapabilityMeta{
Description: "Render a named email template and send it through the configured sender.",
ExampleParams: json.RawMessage(`{"template":"fulfillment-shipped","to":"{{ .Order.CustomerEmail }}","from":"Store <no-reply@example.com>"}`),
})
}
func buildFlowTemplateData(o *OrderGrain, st *flow.State, info flow.HookInfo) flowTemplateData {
errStr := ""
if info.Err != nil {
errStr = info.Err.Error()
}
var lastFulfillment *Fulfillment
if n := len(o.Fulfillments); n > 0 {
lastFulfillment = &o.Fulfillments[n-1]
}
return flowTemplateData{
Order: o,
OrderID: OrderId(st.ID).String(),
Step: info.Step,
Phase: info.Phase,
Error: errStr,
Vars: st.Vars,
Fulfillment: lastFulfillment,
ShippingAddress: decodeEmailTemplateJSON(o.ShippingAddress),
BillingAddress: decodeEmailTemplateJSON(o.BillingAddress),
}
}
func decodeEmailTemplateJSON(raw json.RawMessage) any {
if len(raw) == 0 {
return nil
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return string(raw)
}
return v
}
func RenderTextTemplate(name, src string, data any) (string, error) {
return renderTextTemplate(name, src, data)
}
// RenderOptionalHTMLTemplate renders an HTML template with the given data, or
// returns empty string when src is empty.
func RenderOptionalHTMLTemplate(name, src string, data any) (string, error) {
return renderOptionalHTMLTemplate(name, src, data)
}
func renderTextTemplate(name, src string, data any) (string, error) {
tmpl, err := texttmpl.New(name).Option("missingkey=error").Parse(src)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()), nil
}
func renderOptionalTextTemplate(name, src string, data any) (string, error) {
if strings.TrimSpace(src) == "" {
return "", nil
}
return renderTextTemplate(name, src, data)
}
// SampleTemplateData returns a flowTemplateData populated with sample values
// for previewing email templates in the admin UI.
func SampleTemplateData() any {
now := time.Now().UTC().Format(time.RFC3339)
return flowTemplateData{
Order: &OrderGrain{
Id: 12345,
OrderReference: "ORD-SAMPLE-001",
Currency: "SEK",
Status: StatusCaptured,
CustomerName: "Anna Svensson",
CustomerEmail: "anna@example.com",
TotalAmount: money.Cents(29900),
Lines: []Line{
{Reference: "l1", Sku: "PROD-001", Name: "Sample Product", Quantity: 1, UnitPrice: 29900, TotalAmount: 29900, TaxRate: 2500},
},
PlacedAt: now,
},
OrderID: "abc123",
Fulfillment: &Fulfillment{
ID: "f_ship001",
Carrier: "PostNord",
TrackingNumber: "SE-123456789",
TrackingURI: "https://tracking.postnord.se/123456789",
},
ShippingAddress: map[string]any{
"givenName": "Anna",
"familyName": "Svensson",
"street": "Storgatan 1",
"city": "Stockholm",
"postalCode": "111 22",
"country": "SE",
},
BillingAddress: map[string]any{
"givenName": "Anna",
"familyName": "Svensson",
"street": "Storgatan 1",
"city": "Stockholm",
"postalCode": "111 22",
"country": "SE",
},
}
}
func renderOptionalHTMLTemplate(name, src string, data any) (string, error) {
if strings.TrimSpace(src) == "" {
return "", nil
}
tmpl, err := htmltmpl.New(name).Option("missingkey=error").Parse(src)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()), nil
}
+107
View File
@@ -0,0 +1,107 @@
package order
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// ProfilePreferencesClient is the subset of the profile service's UCP customer
// API that returns email preferences for a given email address.
type ProfilePreferencesClient struct {
baseURL string
httpClient *http.Client
timeout time.Duration
}
// NewProfilePreferencesClient creates a client that talks to the profile
// service's UCP customer endpoint at baseURL (e.g. "http://profile:8080/ucp/v1/customers").
func NewProfilePreferencesClient(baseURL string) *ProfilePreferencesClient {
return &ProfilePreferencesClient{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{Timeout: 5 * time.Second},
timeout: 5 * time.Second,
}
}
// emailPreferencesResponse mirrors ucp.EmailPreferencesResponse for decoding.
type emailPreferencesResponse struct {
OrderEmails *bool `json:"orderEmails,omitempty"`
MarketingEmails *bool `json:"marketingEmails,omitempty"`
}
// customerLookupResponse mirrors the UCP customer response, but we only need
// email preferences.
type customerLookupResponse struct {
EmailPreferences *emailPreferencesResponse `json:"emailPreferences,omitempty"`
}
// fetchPreferences calls GET /by-email/{email} on the profile service and
// returns the parsed email preferences. Returns nil, nil when the profile
// is not found (unknown email -> allow all).
func (c *ProfilePreferencesClient) fetchPreferences(ctx context.Context, email string) (*emailPreferencesResponse, error) {
url := fmt.Sprintf("%s/by-email/%s", c.baseURL, url.PathEscape(email))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("profile prefs: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil // unknown email -> no preferences
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("profile prefs: unexpected status %d: %s", resp.StatusCode, string(body))
}
var data customerLookupResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("profile prefs: decode: %w", err)
}
return data.EmailPreferences, nil
}
// PreferencesChecker wraps ProfilePreferencesClient to implement
// EmailPreferenceChecker.
type PreferencesChecker struct {
client *ProfilePreferencesClient
}
// NewPreferencesChecker creates an EmailPreferenceChecker that looks up
// preferences from the profile service at the given base URL.
func NewPreferencesChecker(profileServiceURL string) *PreferencesChecker {
return &PreferencesChecker{
client: NewProfilePreferencesClient(profileServiceURL),
}
}
// Allowed returns true when the customer has not opted out of the given
// category. Categories are "order" (order confirmations, shipping updates,
// receipts) and "marketing" (promotional offers, newsletters). Returns true
// when preferences are unknown (profile not found) — the safe default.
func (c *PreferencesChecker) Allowed(ctx context.Context, email string, category string) bool {
prefs, err := c.client.fetchPreferences(ctx, email)
if err != nil || prefs == nil {
return true // safe default: allow when unknown
}
switch category {
case "order":
return prefs.OrderEmails == nil || *prefs.OrderEmails
case "marketing":
return prefs.MarketingEmails == nil || *prefs.MarketingEmails
default:
return true // unknown category -> allow
}
}
@@ -0,0 +1,5 @@
{
"subject": "Your order {{ .Order.OrderReference }} has shipped",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour order {{ .Order.OrderReference }} has shipped.\n{{ with .Fulfillment }}Carrier: {{ .Carrier }}\nTracking number: {{ .TrackingNumber }}\n{{ if .TrackingURI }}Track your shipment: {{ .TrackingURI }}\n{{ end }}{{ end }}\nOrder id: {{ .OrderID }}\n",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your order <strong>{{ .Order.OrderReference }}</strong> has shipped.</p>{{ with .Fulfillment }}<ul><li><strong>Carrier:</strong> {{ .Carrier }}</li><li><strong>Tracking number:</strong> {{ .TrackingNumber }}</li>{{ if .TrackingURI }}<li><a href=\"{{ .TrackingURI }}\">Track your shipment</a></li>{{ end }}</ul>{{ end }}<p>Order id: <code>{{ .OrderID }}</code></p>"
}
@@ -0,0 +1,5 @@
{
"subject": "Order {{ .Order.OrderReference }} confirmed",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour order {{ .Order.OrderReference }} has been placed successfully.\n\nWe'll send you an update when your payment is confirmed and your items are on their way.\n\nOrder id: {{ .OrderID }}\n\nThank you for your purchase!",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your order <strong>{{ .Order.OrderReference }}</strong> has been placed successfully.</p><p>We'll send you an update when your payment is confirmed and your items are on their way.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>Thank you for your purchase!</p>"
}
@@ -0,0 +1,5 @@
{
"subject": "Payment received for order {{ .Order.OrderReference }}",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour payment for order {{ .Order.OrderReference }} has been received and confirmed.\n\nWe'll let you know as soon as your items ship.\n\nOrder id: {{ .OrderID }}\n\nThank you for shopping with us!",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your payment for order <strong>{{ .Order.OrderReference }}</strong> has been received and confirmed.</p><p>We'll let you know as soon as your items ship.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>Thank you for shopping with us!</p>"
}
@@ -0,0 +1,5 @@
{
"subject": "Refund issued for order {{ .Order.OrderReference }}",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nA refund has been issued for order {{ .Order.OrderReference }}.\n\nThe amount should appear in your account within a few business days, depending on your payment provider.\n\nOrder id: {{ .OrderID }}\n\nThank you for your patience.",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>A refund has been issued for order <strong>{{ .Order.OrderReference }}</strong>.</p><p>The amount should appear in your account within a few business days, depending on your payment provider.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>Thank you for your patience.</p>"
}
@@ -0,0 +1,5 @@
{
"subject": "Return request received — order {{ .Order.OrderReference }}",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nWe've received your return request for order {{ .Order.OrderReference }}.\n\nWe'll process your return and issue a refund within a few business days. You'll receive an email once the refund is complete.\n\nOrder id: {{ .OrderID }}\n\nIf you have any questions, please don't hesitate to contact us.",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>We've received your return request for order <strong>{{ .Order.OrderReference }}</strong>.</p><p>We'll process your return and issue a refund within a few business days. You'll receive an email once the refund is complete.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>If you have any questions, please don't hesitate to contact us.</p>"
}
@@ -0,0 +1,5 @@
{
"subject": "Package delivered — order {{ .Order.OrderReference }}",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour package for order {{ .Order.OrderReference }} has been delivered.\n{{ with .Fulfillment }}Carrier: {{ .Carrier }}\nTracking number: {{ .TrackingNumber }}{{ end }}\n\nWe hope you're happy with your purchase! If you have any questions, feel free to reply to this email.\n\nOrder id: {{ .OrderID }}",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your package for order <strong>{{ .Order.OrderReference }}</strong> has been delivered.</p>{{ with .Fulfillment }}<ul><li><strong>Carrier:</strong> {{ .Carrier }}</li><li><strong>Tracking number:</strong> {{ .TrackingNumber }}</li></ul>{{ end }}<p>We hope you're happy with your purchase!</p><p>Order id: <code>{{ .OrderID }}</code></p>"
}

Some files were not shown because too many files have changed in this diff Show More