Compare commits
24
Commits
a469b5ea97
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c150ac635c | ||
|
|
80a1e6d05e | ||
|
|
9f1eca07e9 | ||
|
|
e20793a6b3 | ||
|
|
781c1bd171 | ||
|
|
e2f835c01b | ||
|
|
eed53c96fb | ||
|
|
6180706140 | ||
|
|
31530be28f | ||
|
|
dddb5c699a | ||
|
|
8bb0a7a786 | ||
|
|
b6a67e709d | ||
|
|
cecf4abe76 | ||
|
|
f339b60351 | ||
|
|
2e2060da5c | ||
|
|
26fd6dc970 | ||
|
|
1c97a4bdb0 | ||
|
|
4a37cb479c | ||
|
|
83986e7a35 | ||
|
|
b1e99891e9 | ||
|
|
75db64ce75 | ||
|
|
9495c654fa | ||
|
|
7b657901ac | ||
|
|
2e0204a287 |
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+141
-92
@@ -17,6 +17,7 @@ import (
|
||||
"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"
|
||||
@@ -26,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() {
|
||||
@@ -160,35 +159,6 @@ func (c *catalogProjectionCache) Len() int {
|
||||
return base + overlay
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// Route the frame (snapshot begin/chunk/end or delta) into the store;
|
||||
// epoch ordering, snapshot assembly + atomic swap, and tombstones all live
|
||||
// in platform/catalog.ProjectionStore.
|
||||
if err := cache.HandleFrame(ev.Meta, ev.Payload); err != nil {
|
||||
return fmt.Errorf("apply projection frame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
// cartPort is the bare HTTP port. It drives both the local listener and the
|
||||
@@ -201,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)
|
||||
}
|
||||
@@ -228,72 +202,31 @@ func main() {
|
||||
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.
|
||||
|
||||
@@ -387,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()
|
||||
}
|
||||
@@ -419,6 +352,65 @@ func main() {
|
||||
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)
|
||||
@@ -456,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"))
|
||||
@@ -565,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
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
+53
-7
@@ -21,17 +21,16 @@ import (
|
||||
"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() {
|
||||
@@ -114,14 +113,15 @@ func main() {
|
||||
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
|
||||
@@ -212,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)
|
||||
@@ -299,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -160,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
|
||||
|
||||
@@ -19,8 +19,6 @@ import (
|
||||
"git.k6n.net/mats/platform/inventory"
|
||||
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
||||
|
||||
"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]
|
||||
|
||||
@@ -167,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
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func testServer(t *testing.T) *server {
|
||||
applier := &orderedApplier{pool: pool, storage: storage}
|
||||
provider := order.NewPassthroughProvider("legacy", "ref", 15000)
|
||||
|
||||
freg := buildFlowRegistry(applier, provider, nil, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, 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")
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestProjectFulfillmentIntegrationLogs(t *testing.T) {
|
||||
logger := slog.New(slog.NewJSONHandler(&logs, nil))
|
||||
|
||||
provider := order.NewPassthroughProvider("legacy", "ref", 25000)
|
||||
freg := buildFlowRegistry(s.applier, provider, nil, nil, nil, nil, logger)
|
||||
freg := buildFlowRegistry(s.applier, provider, nil, nil, nil, nil, nil, logger)
|
||||
engine := flow.NewEngine(freg, logger)
|
||||
|
||||
st := flow.NewState(801, logger)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -14,14 +14,15 @@ func buildFlowRegistry(
|
||||
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)
|
||||
order.RegisterFlowActions(freg, applier, provider, prefChecker)
|
||||
order.RegisterInventoryReservationActions(freg, applier, inventoryReservations)
|
||||
order.RegisterEmitHook(freg, emitPub)
|
||||
order.RegisterEmailHook(freg, applier, emailSender, emailTemplates)
|
||||
order.RegisterEmailHook(freg, applier, emailSender, emailTemplates, prefChecker)
|
||||
order.RegisterFulfillmentWebhookHook(freg, applier, nil)
|
||||
order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order")
|
||||
registerProjectFlowHooks(freg, applier, logger)
|
||||
|
||||
@@ -120,7 +120,7 @@ func (s *server) createOrderFromCheckoutInternal(ctx context.Context, req *fromC
|
||||
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
|
||||
}
|
||||
|
||||
freg := buildFlowRegistry(s.applier, provider, s.inventoryReservations, nil, nil, nil, s.logger)
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+225
-12
@@ -17,6 +17,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -27,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"
|
||||
@@ -36,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
|
||||
@@ -66,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.
|
||||
@@ -76,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,
|
||||
@@ -95,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)
|
||||
|
||||
@@ -152,14 +171,29 @@ func main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
freg := buildFlowRegistry(applier, provider, inventoryReservations, emitPub, emailSender, emailTemplates, logger)
|
||||
// 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)
|
||||
}
|
||||
@@ -189,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)
|
||||
|
||||
@@ -227,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)
|
||||
@@ -402,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) {
|
||||
@@ -525,9 +722,9 @@ func selectProvider(logger *slog.Logger) order.PaymentProvider {
|
||||
return order.NewMockProvider()
|
||||
}
|
||||
|
||||
// selectEmailSender picks the flow-email transport. SMTP is the only sender
|
||||
// implementation for now; when no SMTP host is configured the hook still exists
|
||||
// in capabilities but errors at run time if a flow tries to use it.
|
||||
// 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") == "" {
|
||||
@@ -536,9 +733,6 @@ func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
|
||||
if kind == "" {
|
||||
kind = "smtp"
|
||||
}
|
||||
if kind != "smtp" {
|
||||
return nil, fmt.Errorf("unsupported ORDER_EMAIL_SENDER %q", kind)
|
||||
}
|
||||
|
||||
fromAddr := strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_ADDRESS"))
|
||||
if fromAddr == "" {
|
||||
@@ -548,6 +742,9 @@ func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
|
||||
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 }),
|
||||
@@ -559,8 +756,24 @@ func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Info("email sender enabled", "kind", kind, "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address)
|
||||
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) {
|
||||
|
||||
@@ -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,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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+102
-3
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"id": "volymrabatt",
|
||||
"name": "Volymrabatt",
|
||||
"description": "Automatisk volymrabatt baserad på varukorgens totalsumma (inkl. moms). Belopp i öre: 20 000–40 000 kr → 5%, 40 000–60 000 kr → 9%, 60 000–90 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 5–14%"
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -129,6 +129,8 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
|
||||
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=
|
||||
@@ -138,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=
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -542,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 {
|
||||
@@ -569,7 +579,14 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
||||
})
|
||||
}
|
||||
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{
|
||||
@@ -577,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
+205
-6
@@ -7,6 +7,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -55,9 +56,57 @@ func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[
|
||||
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)
|
||||
@@ -69,6 +118,27 @@ func (s *DiskStorage[V]) ensureDir() error {
|
||||
// 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
|
||||
@@ -80,9 +150,15 @@ func (s *DiskStorage[V]) writeEvents(id uint64, events []QueueEvent) error {
|
||||
defer w.mu.Unlock()
|
||||
|
||||
for _, evt := range events {
|
||||
if err := s.Append(w.file, evt.Message, evt.TimeStamp); err != nil {
|
||||
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
|
||||
@@ -100,9 +176,12 @@ func (s *DiskStorage[V]) writeMutations(id uint64, msgs ...proto.Message) error
|
||||
|
||||
now := time.Now()
|
||||
for _, m := range msgs {
|
||||
if err := s.Append(w.file, m, now); err != nil {
|
||||
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
|
||||
@@ -201,6 +280,9 @@ 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 {
|
||||
@@ -228,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) {
|
||||
@@ -237,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 {
|
||||
@@ -258,12 +386,21 @@ 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() {
|
||||
@@ -295,3 +432,65 @@ func (s *DiskStorage[V]) openWriterCount() int {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -317,3 +318,86 @@ func TestDiskStorageConcurrentWritesDifferentIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+161
-17
@@ -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)
|
||||
@@ -386,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()
|
||||
@@ -405,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
|
||||
}
|
||||
@@ -417,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.
|
||||
@@ -443,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()
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ func newMockHost(name string) *mockHost {
|
||||
return &mockHost{name: name, healthy: true}
|
||||
}
|
||||
|
||||
func (m *mockHost) AnnounceExpiry(ids []uint64) {
|
||||
func (m *mockHost) AnnounceExpiry(ids []uint64, _ []int64) {
|
||||
m.mu.Lock()
|
||||
m.expiry = append(m.expiry, ids...)
|
||||
m.mu.Unlock()
|
||||
@@ -84,7 +84,7 @@ func (m *mockHost) Ping() bool { return m.healthy }
|
||||
|
||||
func (m *mockHost) IsHealthy() bool { return m.healthy }
|
||||
|
||||
func (m *mockHost) AnnounceOwnership(_ string, ids []uint64) {
|
||||
func (m *mockHost) AnnounceOwnership(_ string, ids []uint64, _ []int64) {
|
||||
m.mu.Lock()
|
||||
m.ownership = append(m.ownership, ids...)
|
||||
m.mu.Unlock()
|
||||
@@ -247,11 +247,19 @@ 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: time.Now()}
|
||||
pool.grains[7] = &testGrain{id: 7, accessed: spawnTime}
|
||||
pool.localMu.Unlock()
|
||||
|
||||
if err := pool.HandleOwnershipChange(host.name, []uint64{7}); err != nil {
|
||||
remoteStamp := spawnTime.UnixNano() - 1
|
||||
if err := pool.HandleOwnershipChange(host.name, []uint64{7}, []int64{remoteStamp}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -268,6 +276,121 @@ func TestSimpleGrainPoolHandleOwnershipChange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
+29
-7
@@ -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 {
|
||||
|
||||
@@ -15,12 +15,14 @@ import (
|
||||
"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-cart-actor/pkg/promotions"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
@@ -49,6 +51,8 @@ type Config struct {
|
||||
CheckoutDataDir string
|
||||
// ProfileDataDir holds the profile event logs (optional, default "" — customer endpoints disabled).
|
||||
ProfileDataDir string
|
||||
// PromotionsFile holds the path to promotions.json (optional).
|
||||
PromotionsFile string
|
||||
}
|
||||
|
||||
// App is the commerce admin control plane. Construct with New, register its
|
||||
@@ -57,6 +61,7 @@ type App struct {
|
||||
fs *FileServer
|
||||
hub *Hub
|
||||
cs *CustomerServer
|
||||
OnVouchersChange func(codes []string)
|
||||
}
|
||||
|
||||
// New constructs the commerce admin: it opens the cart/checkout disk event-log
|
||||
@@ -75,6 +80,72 @@ func New(cfg Config) (*App, error) {
|
||||
|
||||
_ = 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)
|
||||
@@ -91,7 +162,7 @@ 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(),
|
||||
cs: customerSrv,
|
||||
}, nil
|
||||
@@ -101,6 +172,7 @@ 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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -2,9 +2,11 @@ package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
"git.k6n.net/mats/platform/inventory"
|
||||
)
|
||||
|
||||
@@ -71,6 +73,100 @@ func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sk
|
||||
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 {
|
||||
|
||||
reg := actor.NewMutationRegistry()
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -242,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,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
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
+33
-3
@@ -63,7 +63,7 @@ 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) {
|
||||
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 {
|
||||
@@ -243,7 +243,11 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
||||
ExampleParams: json.RawMessage(`{"carrier":"dropshipper","trackingNumber":"TRACK-123","trackingUri":"https://tracking.example/TRACK-123","lines":[{"reference":"l1","quantity":1}]}`),
|
||||
})
|
||||
|
||||
registerPredicates(reg, app)
|
||||
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
|
||||
@@ -251,7 +255,7 @@ 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.PredicateWithMeta("has_customer_email", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||
@@ -289,4 +293,30 @@ func registerPredicates(reg *flow.Registry, app Applier) {
|
||||
}
|
||||
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."})
|
||||
}
|
||||
}
|
||||
|
||||
+87
-3
@@ -21,6 +21,7 @@ import (
|
||||
"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.
|
||||
@@ -229,6 +230,12 @@ type EmailTemplate struct {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -330,6 +337,7 @@ type emailHookParams struct {
|
||||
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 {
|
||||
@@ -344,16 +352,30 @@ type flowTemplateData struct {
|
||||
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. Hook params:
|
||||
// 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>"
|
||||
// "from": "Warehouse <warehouse@example.com>",
|
||||
// "category": "order"
|
||||
// }
|
||||
func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, templates EmailTemplateStore) {
|
||||
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")
|
||||
@@ -414,6 +436,14 @@ func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, temp
|
||||
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)
|
||||
@@ -473,6 +503,16 @@ func decodeEmailTemplateJSON(raw json.RawMessage) any {
|
||||
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 {
|
||||
@@ -492,6 +532,50 @@ func renderOptionalTextTemplate(name, src string, data any) (string, error) {
|
||||
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
|
||||
|
||||
@@ -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": "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>"
|
||||
}
|
||||
+10
-1
@@ -37,13 +37,22 @@ func newPool(t *testing.T) *actor.SimpleGrainPool[OrderGrain] {
|
||||
return pool
|
||||
}
|
||||
|
||||
// alwaysAllowChecker is a mock EmailPreferenceChecker that allows every email.
|
||||
type alwaysAllowChecker struct{}
|
||||
|
||||
func (alwaysAllowChecker) Allowed(_ context.Context, _ string, _ string) bool { return true }
|
||||
|
||||
func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry {
|
||||
reg := flow.NewRegistry()
|
||||
flow.RegisterBuiltinHooks(reg)
|
||||
RegisterFlowActions(reg, pool, provider)
|
||||
RegisterFlowActions(reg, pool, provider, alwaysAllowChecker{})
|
||||
// place-and-pay references the emit_order_created hook; register it (nil
|
||||
// publisher = no-op) so flow validation passes in tests too.
|
||||
RegisterOrderCreatedEmit(reg, pool, nil, "order", "order")
|
||||
// place-and-pay also references send_email hooks; register it with nil
|
||||
// sender and nil templates so validation passes (runtime = error, tests
|
||||
// that exercise email have their own sender/templates).
|
||||
RegisterEmailHook(reg, pool, nil, nil)
|
||||
return reg
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "fulfill-order",
|
||||
"description": "Create a fulfillment (shipment) for an order and send the shipping notification to the customer.",
|
||||
"steps": [
|
||||
{
|
||||
"name": "fulfill",
|
||||
"action": "create_fulfillment",
|
||||
"hooks": {
|
||||
"after": [
|
||||
{ "type": "log", "params": { "message": "fulfillment created" } },
|
||||
{ "type": "send_email", "when": "has_order_email_consent", "params": { "template": "fulfillment-shipped" } },
|
||||
{ "type": "fulfillment_webhook", "params": { "url": "", "method": "POST" }, "continueOnError": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "place-and-pay",
|
||||
"description": "Place an order and take payment in one transaction. Authorization is compensated (voided + order cancelled) if capture fails.",
|
||||
"description": "Place an order and take payment in one transaction. Authorization is compensated (voided + order cancelled) if capture fails. Sends order confirmation and payment receipt emails when a customer email is present.",
|
||||
"steps": [
|
||||
{
|
||||
"name": "place",
|
||||
"action": "place_order",
|
||||
"hooks": {
|
||||
"after": [{ "type": "log", "params": { "message": "order placed" } }]
|
||||
"after": [
|
||||
{ "type": "log", "params": { "message": "order placed" } },
|
||||
{ "type": "send_email", "when": "has_order_email_consent", "params": { "template": "order-confirmation" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -24,6 +27,7 @@
|
||||
"hooks": {
|
||||
"after": [
|
||||
{ "type": "log", "params": { "message": "payment captured" } },
|
||||
{ "type": "send_email", "when": "has_order_email_consent", "params": { "template": "payment-receipt" } },
|
||||
{ "type": "emit_order_created" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/mailersend/mailersend-go"
|
||||
)
|
||||
|
||||
// MailerSendEmailSender sends email through the MailerSend API using the
|
||||
// official Go SDK. It implements the EmailSender interface.
|
||||
type MailerSendEmailSender struct {
|
||||
client *mailersend.Mailersend
|
||||
defaultFrom mail.Address
|
||||
}
|
||||
|
||||
// NewMailerSendEmailSender creates a new MailerSend-backed sender.
|
||||
func NewMailerSendEmailSender(apiKey string, defaultFrom mail.Address) (*MailerSendEmailSender, error) {
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
return nil, fmt.Errorf("mailersend sender: missing API key")
|
||||
}
|
||||
if strings.TrimSpace(defaultFrom.Address) == "" {
|
||||
return nil, fmt.Errorf("mailersend sender: missing default from address")
|
||||
}
|
||||
return &MailerSendEmailSender{
|
||||
client: mailersend.NewMailersend(apiKey),
|
||||
defaultFrom: defaultFrom,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DefaultFrom returns the default sender address configured for this sender.
|
||||
func (s *MailerSendEmailSender) DefaultFrom() mail.Address { return s.defaultFrom }
|
||||
|
||||
// Send delivers the rendered email message through the MailerSend API.
|
||||
func (s *MailerSendEmailSender) Send(ctx context.Context, msg EmailMessage) error {
|
||||
if strings.TrimSpace(msg.From.Address) == "" {
|
||||
return fmt.Errorf("mailersend sender: missing from address")
|
||||
}
|
||||
if len(msg.To) == 0 {
|
||||
return fmt.Errorf("mailersend sender: missing recipients")
|
||||
}
|
||||
if strings.TrimSpace(msg.Subject) == "" {
|
||||
return fmt.Errorf("mailersend sender: missing subject")
|
||||
}
|
||||
if strings.TrimSpace(msg.TextBody) == "" && strings.TrimSpace(msg.HTMLBody) == "" {
|
||||
return fmt.Errorf("mailersend sender: missing body")
|
||||
}
|
||||
|
||||
message := s.client.Email.NewMessage()
|
||||
|
||||
fromName := msg.From.Name
|
||||
if fromName == "" {
|
||||
fromName = s.defaultFrom.Name
|
||||
}
|
||||
message.SetFrom(mailersend.From{
|
||||
Name: fromName,
|
||||
Email: msg.From.Address,
|
||||
})
|
||||
|
||||
recipients := make([]mailersend.Recipient, 0, len(msg.To))
|
||||
for _, rcpt := range msg.To {
|
||||
recipients = append(recipients, mailersend.Recipient{
|
||||
Name: rcpt.Name,
|
||||
Email: rcpt.Address,
|
||||
})
|
||||
}
|
||||
message.SetRecipients(recipients)
|
||||
message.SetSubject(msg.Subject)
|
||||
|
||||
if strings.TrimSpace(msg.HTMLBody) != "" {
|
||||
message.SetHTML(msg.HTMLBody)
|
||||
}
|
||||
if strings.TrimSpace(msg.TextBody) != "" {
|
||||
message.SetText(msg.TextBody)
|
||||
}
|
||||
|
||||
_, err := s.client.Email.Send(ctx, message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailersend sender: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// emailIndexEntry captures the email preferences snapshot for a profile.
|
||||
type emailIndexEntry struct {
|
||||
ID uint64
|
||||
EmailPreferences *EmailPreferences
|
||||
}
|
||||
|
||||
// ProfileEmailIndex is a thread-safe in-memory map from email address to
|
||||
// profile ID and email preferences snapshot. It is populated at startup by
|
||||
// scanning the profile storage directory, and updated when SetProfile
|
||||
// mutations change the email or preferences.
|
||||
type ProfileEmailIndex struct {
|
||||
mu sync.RWMutex
|
||||
byID map[uint64]string // profileID → email (for cleanup on email change)
|
||||
idx map[string]emailIndexEntry // email → profile info
|
||||
}
|
||||
|
||||
// NewProfileEmailIndex returns an empty index.
|
||||
func NewProfileEmailIndex() *ProfileEmailIndex {
|
||||
return &ProfileEmailIndex{
|
||||
byID: make(map[uint64]string),
|
||||
idx: make(map[string]emailIndexEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// Set records or updates the email→profile mapping. When email is empty the
|
||||
// entry is removed (profile was anonymized).
|
||||
func (ix *ProfileEmailIndex) Set(profileID uint64, email string, prefs *EmailPreferences) {
|
||||
ix.mu.Lock()
|
||||
defer ix.mu.Unlock()
|
||||
|
||||
// Remove any previous email for this profile.
|
||||
if oldEmail, ok := ix.byID[profileID]; ok {
|
||||
delete(ix.idx, oldEmail)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(email) == "" {
|
||||
delete(ix.byID, profileID)
|
||||
return
|
||||
}
|
||||
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
ix.byID[profileID] = email
|
||||
ix.idx[email] = emailIndexEntry{
|
||||
ID: profileID,
|
||||
EmailPreferences: prefs,
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup returns the email preferences for the given email. The second return
|
||||
// value is false when the email is not found.
|
||||
func (ix *ProfileEmailIndex) Lookup(email string) (emailIndexEntry, bool) {
|
||||
ix.mu.RLock()
|
||||
defer ix.mu.RUnlock()
|
||||
entry, ok := ix.idx[strings.ToLower(strings.TrimSpace(email))]
|
||||
return entry, ok
|
||||
}
|
||||
|
||||
// BuildEmailIndex populates the index by scanning the profile storage
|
||||
// directory and replaying each event log into a fresh ProfileGrain. It must
|
||||
// be called once at service startup after the mutation registry and storage
|
||||
// are initialised.
|
||||
//
|
||||
// dir — the same path passed to actor.NewDiskStorage (e.g. "data/profiles").
|
||||
// replayer — an *actor.StateStorage for reading event log files.
|
||||
// reg — the profile mutation registry.
|
||||
// ix — the (empty) index to populate.
|
||||
func BuildEmailIndex(dir string, replayer interface {
|
||||
Load(r io.Reader, onMessage func(msg proto.Message, timeStamp time.Time)) error
|
||||
}, reg actor.MutationRegistry, ix *ProfileEmailIndex) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
log.Printf("email index: cannot read profile dir %s: %v — starting with empty index", dir, err)
|
||||
return
|
||||
}
|
||||
|
||||
var count int
|
||||
ctx := context.Background()
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".events.log") {
|
||||
continue
|
||||
}
|
||||
idStr := strings.TrimSuffix(entry.Name(), ".events.log")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
p := NewProfileGrain(id, time.Now())
|
||||
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
log.Printf("email index: read %s: %v", entry.Name(), err)
|
||||
continue
|
||||
}
|
||||
if err := replayer.Load(bytes.NewReader(data), func(msg proto.Message, _ time.Time) { //nolint:govet
|
||||
_, _ = reg.Apply(ctx, p, msg)
|
||||
}); err != nil {
|
||||
log.Printf("email index: replay %s: %v", entry.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
if p.Email != "" {
|
||||
ix.Set(p.Id, p.Email, p.EmailPreferences)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("email index: built %d entries from %s", count, dir)
|
||||
}
|
||||
@@ -29,5 +29,16 @@ func HandleSetProfile(p *ProfileGrain, m *messages.SetProfile) error {
|
||||
if m.AvatarUrl != nil {
|
||||
p.AvatarUrl = *m.AvatarUrl
|
||||
}
|
||||
if m.OrderEmails != nil || m.MarketingEmails != nil {
|
||||
if p.EmailPreferences == nil {
|
||||
p.EmailPreferences = &EmailPreferences{}
|
||||
}
|
||||
if m.OrderEmails != nil {
|
||||
p.EmailPreferences.OrderEmails = m.OrderEmails
|
||||
}
|
||||
if m.MarketingEmails != nil {
|
||||
p.EmailPreferences.MarketingEmails = m.MarketingEmails
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,6 +29,15 @@ type LinkedOrder struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// EmailPreferences controls which transactional emails a customer receives.
|
||||
// All fields default to true (opted in) when nil/omitted.
|
||||
type EmailPreferences struct {
|
||||
// OrderEmails sends order confirmations, shipping updates, receipts.
|
||||
OrderEmails *bool `json:"orderEmails,omitempty"`
|
||||
// MarketingEmails sends promotional offers, newsletters, product recommendations.
|
||||
MarketingEmails *bool `json:"marketingEmails,omitempty"`
|
||||
}
|
||||
|
||||
// StoredAddress is a saved address in the user's address book.
|
||||
type StoredAddress struct {
|
||||
Id uint32 `json:"id"`
|
||||
@@ -65,6 +74,7 @@ type ProfileGrain struct {
|
||||
Checkouts []LinkedCheckout `json:"checkouts,omitempty"`
|
||||
Orders []LinkedOrder `json:"orders,omitempty"`
|
||||
Addresses []StoredAddress `json:"addresses,omitempty"`
|
||||
EmailPreferences *EmailPreferences `json:"emailPreferences,omitempty"`
|
||||
}
|
||||
|
||||
// NewProfileGrain returns an empty profile grain for id.
|
||||
|
||||
+160
-28
@@ -31,7 +31,7 @@ type Effect interface {
|
||||
// Apply mutates the cart for a qualifying action and returns the discount it
|
||||
// took (nil for non-monetary effects such as free shipping) plus whether
|
||||
// anything took effect worth recording.
|
||||
Apply(g *cart.CartGrain, rule PromotionRule, a Action) (discount *cart.Price, applied bool)
|
||||
Apply(g *cart.CartGrain, rule PromotionRule, a Action) (discount *cart.Price, itemIds []uint32, applied bool)
|
||||
// Progress reports how far the cart is from (further) unlocking this action,
|
||||
// as an open key/value payload. qualified says whether the owning rule
|
||||
// currently applies, letting the effect distinguish "remaining to unlock"
|
||||
@@ -77,8 +77,9 @@ func (s *PromotionService) ApplyResults(g *cart.CartGrain, results []EvaluationR
|
||||
}
|
||||
recorded := false
|
||||
if res.Applicable {
|
||||
if discount, applied := eff.Apply(g, res.Rule, a); applied {
|
||||
if discount, itemIds, applied := eff.Apply(g, res.Rule, a); applied {
|
||||
entry.Discount = discount
|
||||
entry.ItemIds = itemIds
|
||||
recorded = true
|
||||
}
|
||||
}
|
||||
@@ -111,9 +112,9 @@ type percentageDiscountEffect struct{}
|
||||
|
||||
func (percentageDiscountEffect) Type() ActionType { return ActionPercentageDiscount }
|
||||
|
||||
func (percentageDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
|
||||
func (percentageDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, []uint32, bool) {
|
||||
d := applyPercentageDiscount(g, percentFromValue(a.Value))
|
||||
return d, d != nil
|
||||
return d, nil, d != nil
|
||||
}
|
||||
|
||||
func (percentageDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
||||
@@ -129,8 +130,8 @@ type freeShippingEffect struct{}
|
||||
|
||||
func (freeShippingEffect) Type() ActionType { return ActionFreeShipping }
|
||||
|
||||
func (freeShippingEffect) Apply(_ *cart.CartGrain, _ PromotionRule, _ Action) (*cart.Price, bool) {
|
||||
return nil, true
|
||||
func (freeShippingEffect) Apply(_ *cart.CartGrain, _ PromotionRule, _ Action) (*cart.Price, []uint32, bool) {
|
||||
return nil, nil, true
|
||||
}
|
||||
|
||||
func (freeShippingEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
||||
@@ -148,9 +149,9 @@ type tieredDiscountEffect struct{}
|
||||
|
||||
func (tieredDiscountEffect) Type() ActionType { return ActionTieredDiscount }
|
||||
|
||||
func (tieredDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
|
||||
func (tieredDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, []uint32, bool) {
|
||||
d := applyTieredDiscount(g, a)
|
||||
return d, d != nil
|
||||
return d, nil, d != nil
|
||||
}
|
||||
|
||||
func (tieredDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
||||
@@ -284,24 +285,53 @@ func applyTieredDiscount(g *cart.CartGrain, a Action) *cart.Price {
|
||||
return applyPercentageDiscount(g, pct)
|
||||
}
|
||||
|
||||
// applyPercentageDiscount removes pct% of the current cart total, preserving the
|
||||
// VAT-rate breakdown proportionally, recording it in TotalDiscount and returning
|
||||
// the discount applied (nil if nothing was taken off).
|
||||
// applyPercentageDiscount removes pct% of each line's row total, sums
|
||||
// the per-line amounts into the cart's TotalDiscount, and returns the
|
||||
// total (nil if nothing was taken off). Per-line first then sum
|
||||
// (instead of total first then split) avoids penny-level rounding
|
||||
// gaps where the per-line amounts no longer sum to the reported
|
||||
// total. The per-line amount is also added to each line's Discount
|
||||
// field on top of the OrgPrice-based discount that UpdateTotals
|
||||
// already set, so consumers (the /promotions/evaluate-with-cart
|
||||
// preview, the checkout payload mapper) can see the effective unit
|
||||
// price per item.
|
||||
func applyPercentageDiscount(g *cart.CartGrain, pct float64) *cart.Price {
|
||||
if pct <= 0 || g.TotalPrice.IncVat <= 0 {
|
||||
return nil
|
||||
}
|
||||
discount := scalePrice(g.TotalPrice, pct)
|
||||
if discount.IncVat <= 0 {
|
||||
totalDiscount := cart.NewPrice()
|
||||
for _, item := range g.Items {
|
||||
if item == nil || item.TotalPrice.IncVat <= 0 {
|
||||
continue
|
||||
}
|
||||
itemDisc := scalePrice(&item.TotalPrice, pct)
|
||||
if itemDisc.IncVat <= 0 {
|
||||
continue
|
||||
}
|
||||
// Clamp to this line's row total so a single item can't go
|
||||
// negative when its per-line discount (post-rounding) ends
|
||||
// up larger than its row. itemDisc is *cart.Price; assign
|
||||
// through the pointer to overwrite the rounded value with
|
||||
// the raw row total when needed.
|
||||
if itemDisc.IncVat > item.TotalPrice.IncVat {
|
||||
*itemDisc = item.TotalPrice
|
||||
}
|
||||
if item.Discount == nil {
|
||||
item.Discount = cart.NewPrice()
|
||||
}
|
||||
item.Discount.Add(*itemDisc)
|
||||
totalDiscount.Add(*itemDisc)
|
||||
}
|
||||
if totalDiscount.IncVat <= 0 {
|
||||
return nil
|
||||
}
|
||||
// Never discount below zero.
|
||||
if discount.IncVat > g.TotalPrice.IncVat {
|
||||
discount = g.TotalPrice
|
||||
// Safety net for any aggregate rounding overshoot.
|
||||
if totalDiscount.IncVat > g.TotalPrice.IncVat {
|
||||
totalDiscount = g.TotalPrice
|
||||
}
|
||||
g.TotalDiscount.Add(*discount)
|
||||
g.TotalPrice.Subtract(*discount)
|
||||
return discount
|
||||
g.TotalDiscount.Add(*totalDiscount)
|
||||
g.TotalPrice.Subtract(*totalDiscount)
|
||||
return totalDiscount
|
||||
}
|
||||
|
||||
// selectTier returns the discount percent for the band the total falls into.
|
||||
@@ -387,7 +417,7 @@ type unitItem struct {
|
||||
price money.Cents
|
||||
}
|
||||
|
||||
func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*cart.Price, bool) {
|
||||
func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*cart.Price, []uint32, bool) {
|
||||
config := a.Config
|
||||
buy := int(firstNum(config, "buy"))
|
||||
if buy <= 0 {
|
||||
@@ -416,7 +446,7 @@ func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*c
|
||||
n := len(units)
|
||||
numFree := (n / (buy + get)) * get
|
||||
if numFree <= 0 {
|
||||
return nil, false
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
// Sort units by price ascending so we discount the cheapest ones
|
||||
@@ -431,13 +461,26 @@ func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*c
|
||||
})
|
||||
|
||||
totalDiscount := cart.NewPrice()
|
||||
affectedMap := make(map[uint32]bool)
|
||||
for i := 0; i < numFree; i++ {
|
||||
unitDiscount := scalePrice(&units[i].item.Price, discount)
|
||||
totalDiscount.Add(*unitDiscount)
|
||||
affectedMap[units[i].item.Id] = true
|
||||
// Per-line distribution: add the per-unit discount to the
|
||||
// item's Discount field on top of the OrgPrice-based
|
||||
// discount that UpdateTotals already set. If the same item
|
||||
// is the free unit multiple times (e.g. qty 3 with
|
||||
// buy-2-get-1 and a second buy-2-get-1), Add is called
|
||||
// per free unit so the cumulative amount lands on the
|
||||
// right line.
|
||||
if units[i].item.Discount == nil {
|
||||
units[i].item.Discount = cart.NewPrice()
|
||||
}
|
||||
units[i].item.Discount.Add(*unitDiscount)
|
||||
}
|
||||
|
||||
if totalDiscount.IncVat <= 0 {
|
||||
return nil, false
|
||||
return nil, nil, false
|
||||
}
|
||||
// Never discount below zero.
|
||||
if totalDiscount.IncVat > g.TotalPrice.IncVat {
|
||||
@@ -445,7 +488,14 @@ func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*c
|
||||
}
|
||||
g.TotalDiscount.Add(*totalDiscount)
|
||||
g.TotalPrice.Subtract(*totalDiscount)
|
||||
return totalDiscount, true
|
||||
|
||||
var itemIds []uint32
|
||||
for id := range affectedMap {
|
||||
itemIds = append(itemIds, id)
|
||||
}
|
||||
slices.Sort(itemIds)
|
||||
|
||||
return totalDiscount, itemIds, true
|
||||
}
|
||||
|
||||
func (buyXGetYEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
||||
@@ -460,9 +510,9 @@ type bundleDiscountEffect struct{}
|
||||
|
||||
func (bundleDiscountEffect) Type() ActionType { return ActionBundleDiscount }
|
||||
|
||||
func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
|
||||
func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, []uint32, bool) {
|
||||
if a.BundleConfig == nil {
|
||||
return nil, false
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
// 1. Represent all cart items as individual units
|
||||
@@ -545,10 +595,11 @@ func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action)
|
||||
}
|
||||
|
||||
if len(formedBundles) == 0 {
|
||||
return nil, false
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
totalDiscount := cart.NewPrice()
|
||||
affectedMap := make(map[uint32]bool)
|
||||
for _, bundle := range formedBundles {
|
||||
// Calculate the original bundle price
|
||||
bundlePrice := cart.NewPrice()
|
||||
@@ -579,11 +630,23 @@ func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action)
|
||||
|
||||
if bundleDiscount != nil && bundleDiscount.IncVat > 0 {
|
||||
totalDiscount.Add(*bundleDiscount)
|
||||
// Per-line distribution: split the bundle discount
|
||||
// across the entries in `bundle` proportionally to
|
||||
// each entry's per-unit price (same item can appear
|
||||
// multiple times if it's in the bundle multiple
|
||||
// times). distributeBundleDiscount walks the entries
|
||||
// and adds the share to each item's Discount field,
|
||||
// with the last entry absorbing any rounding remainder
|
||||
// so the per-line sum matches bundleDiscount exactly.
|
||||
distributeBundleDiscount(bundle, bundleDiscount)
|
||||
for _, item := range bundle {
|
||||
affectedMap[item.Id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalDiscount.IncVat <= 0 {
|
||||
return nil, false
|
||||
return nil, nil, false
|
||||
}
|
||||
// Never discount below zero.
|
||||
if totalDiscount.IncVat > g.TotalPrice.IncVat {
|
||||
@@ -591,7 +654,14 @@ func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action)
|
||||
}
|
||||
g.TotalDiscount.Add(*totalDiscount)
|
||||
g.TotalPrice.Subtract(*totalDiscount)
|
||||
return totalDiscount, true
|
||||
|
||||
var itemIds []uint32
|
||||
for id := range affectedMap {
|
||||
itemIds = append(itemIds, id)
|
||||
}
|
||||
slices.Sort(itemIds)
|
||||
|
||||
return totalDiscount, itemIds, true
|
||||
}
|
||||
|
||||
func (bundleDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
||||
@@ -602,6 +672,68 @@ func (bundleDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a
|
||||
// Helpers
|
||||
// ----------------------------
|
||||
|
||||
// distributeBundleDiscount splits bundleDiscount across the entries
|
||||
// in bundleItems proportionally to each entry's per-unit price, and
|
||||
// adds the share to each item's Discount field. The same item may
|
||||
// appear multiple times in bundleItems (when it's in the bundle
|
||||
// multiple times); each entry gets its own share, and Add on the
|
||||
// item's Discount field is cumulative. The last entry absorbs any
|
||||
// rounding remainder on IncVat (and on each VAT rate) so the
|
||||
// per-line sum matches bundleDiscount exactly. No-op when
|
||||
// bundleDiscount is nil/zero or bundleItems is empty.
|
||||
func distributeBundleDiscount(bundleItems []*cart.CartItem, bundleDiscount *cart.Price) {
|
||||
if bundleDiscount == nil || bundleDiscount.IncVat <= 0 || len(bundleItems) == 0 {
|
||||
return
|
||||
}
|
||||
total := int64(0)
|
||||
prices := make([]int64, len(bundleItems))
|
||||
for i, item := range bundleItems {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
prices[i] = item.Price.IncVat.Int64()
|
||||
total += prices[i]
|
||||
}
|
||||
if total <= 0 {
|
||||
return
|
||||
}
|
||||
var allocated int64
|
||||
for i, item := range bundleItems {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
var shareIncVat int64
|
||||
if i == len(bundleItems)-1 {
|
||||
shareIncVat = bundleDiscount.IncVat.Int64() - allocated
|
||||
} else {
|
||||
shareIncVat = bundleDiscount.IncVat.Int64() * prices[i] / total
|
||||
}
|
||||
if shareIncVat <= 0 {
|
||||
continue
|
||||
}
|
||||
sharePrice := &cart.Price{
|
||||
IncVat: money.Cents(shareIncVat),
|
||||
VatRates: make(map[int]money.Cents, len(bundleDiscount.VatRates)),
|
||||
}
|
||||
// Pro-rate each VAT rate by the same share fraction so the
|
||||
// VAT breakdown on the per-line discount matches the
|
||||
// proportional split. Guard against the (degenerate) case
|
||||
// where bundleDiscount.IncVat is somehow 0 here — the
|
||||
// outer guard at the top should prevent this, but be
|
||||
// safe.
|
||||
if bundleDiscount.IncVat.Int64() > 0 {
|
||||
for rate, amount := range bundleDiscount.VatRates {
|
||||
sharePrice.VatRates[rate] = money.Cents(amount.Int64() * shareIncVat / bundleDiscount.IncVat.Int64())
|
||||
}
|
||||
}
|
||||
if item.Discount == nil {
|
||||
item.Discount = cart.NewPrice()
|
||||
}
|
||||
item.Discount.Add(*sharePrice)
|
||||
allocated += shareIncVat
|
||||
}
|
||||
}
|
||||
|
||||
func eligibleItems(rule PromotionRule, g *cart.CartGrain) []*cart.CartItem {
|
||||
var categories []string
|
||||
var productIDs []string
|
||||
|
||||
@@ -184,6 +184,13 @@ func TestBuyXGetYEffect(t *testing.T) {
|
||||
if got := g.TotalPrice.IncVat; got != 2000 {
|
||||
t.Errorf("total price = %d, want 2000", got)
|
||||
}
|
||||
if len(g.AppliedPromotions) != 1 {
|
||||
t.Fatalf("expected 1 applied promotion, got %d", len(g.AppliedPromotions))
|
||||
}
|
||||
itemIds := g.AppliedPromotions[0].ItemIds
|
||||
if len(itemIds) != 1 || itemIds[0] != 1 {
|
||||
t.Errorf("expected affected itemIds [1], got %v", itemIds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleDiscountEffect(t *testing.T) {
|
||||
@@ -247,4 +254,11 @@ func TestBundleDiscountEffect(t *testing.T) {
|
||||
if got := g.TotalPrice.IncVat; got != 4000 {
|
||||
t.Errorf("total price = %d, want 4000", got)
|
||||
}
|
||||
if len(g.AppliedPromotions) != 1 {
|
||||
t.Fatalf("expected 1 applied promotion, got %d", len(g.AppliedPromotions))
|
||||
}
|
||||
itemIds := g.AppliedPromotions[0].ItemIds
|
||||
if len(itemIds) != 2 || itemIds[0] != 1 || itemIds[1] != 2 {
|
||||
t.Errorf("expected affected itemIds [1, 2], got %v", itemIds)
|
||||
}
|
||||
}
|
||||
|
||||
+165
-27
@@ -2,6 +2,7 @@ package promotions
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
@@ -16,6 +17,11 @@ import (
|
||||
// creating or mutating a cart. It builds a synthetic CartGrain from the request,
|
||||
// runs the same EvaluateAll + ApplyResults path the live cart uses, and returns
|
||||
// the resulting totals plus the applied/pending effect breakdown.
|
||||
//
|
||||
// The per-line breakdown (cart.EvaluatedItem) is computed via the canonical
|
||||
// cart.MapEvaluatedItems so the preview and post-add responses share shape
|
||||
// and rounding rules — see pkg/cart/evaluated_item.go for the type and the
|
||||
// map function.
|
||||
|
||||
// EvalItem is one — possibly partial — cart line for a stateless evaluation.
|
||||
// Missing fields fall back to sensible defaults (qty 1, 25% VAT) so the engine
|
||||
@@ -42,29 +48,138 @@ type EvaluateRequest struct {
|
||||
}
|
||||
|
||||
// EvaluateResponse is the outcome of a stateless evaluation: the totals after
|
||||
// promotions and the per-promotion breakdown (applied discounts plus pending
|
||||
// "spend X more for ..." nudges).
|
||||
// promotions, the per-promotion breakdown (applied discounts plus pending
|
||||
// "spend X more for ..." nudges), and the per-line breakdown so the
|
||||
// storefront can render the real (post-discount) unit price for each item.
|
||||
//
|
||||
// Items is the same []cart.EvaluatedItem shape the live cart exposes on
|
||||
// CartGrain.EvaluatedItems — referenced from cart so the preview and the
|
||||
// live response match byte-for-byte on the wire.
|
||||
type EvaluateResponse struct {
|
||||
TotalPrice *cart.Price `json:"totalPrice"`
|
||||
TotalDiscount *cart.Price `json:"totalDiscount"`
|
||||
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"`
|
||||
Items []cart.EvaluatedItem `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// Evaluate runs rules against a synthetic cart built from req and returns the
|
||||
// resulting totals and effects without touching any real cart state.
|
||||
func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest) EvaluateResponse {
|
||||
g := req.toCart(s.DefaultTaxProvider)
|
||||
g.UpdateTotals()
|
||||
ctx := NewContextFromCart(g, req.contextOptions()...)
|
||||
results, _ := s.EvaluateAll(rules, ctx)
|
||||
s.ApplyResults(g, results, ctx)
|
||||
s.EvaluateAndApply(rules, g, req.ContextOptions()...)
|
||||
return EvaluateResponse{
|
||||
TotalPrice: g.TotalPrice,
|
||||
TotalDiscount: g.TotalDiscount,
|
||||
AppliedPromotions: g.AppliedPromotions,
|
||||
Items: cart.MapEvaluatedItems(g),
|
||||
}
|
||||
}
|
||||
|
||||
// EvaluateAndApply runs the canonical promotion-evaluation pipeline on
|
||||
// an existing grain. It is the single entry point every caller that
|
||||
// needs "what would the engine do for this cart" MUST go through —
|
||||
// the live cart processor in cmd/cart/main.go's reg.RegisterProcessor
|
||||
// and the /promotions/evaluate-with-cart preview handler in
|
||||
// cmd/cart/promotions_evaluate.go both call this method, so the
|
||||
// preview and post-add behavior stay in lockstep (especially in the
|
||||
// coupon+promotion-overlap edge case where a promotion rule with a
|
||||
// coupon_code condition would otherwise stomp a voucher the customer
|
||||
// has applied). If a new caller needs the same pipeline, add it here
|
||||
// — do not duplicate the steps.
|
||||
//
|
||||
// The pipeline is:
|
||||
//
|
||||
// 1. Clear Vouchers.BypassedByPromotions on every voucher so the
|
||||
// re-eval trigger (step 5) works correctly on each invocation —
|
||||
// otherwise a coupon that was already bypassed on a previous
|
||||
// pass would not be re-marked and step 5 would not fire.
|
||||
// 2. UpdateTotals on the grain (recomputes TotalPrice, TotalDiscount
|
||||
// from the current items + vouchers).
|
||||
// 3. Build a context from the grain (via NewContextFromCart with the
|
||||
// supplied opts; defaults to WithNow(time.Now()) only when no
|
||||
// opts are passed at all — callers that pass non-empty opts
|
||||
// without WithNow get the engine's built-in default).
|
||||
// 4. EvaluateAll against the rules.
|
||||
// 5. markCouponsBypassed scans the results for applicable rules
|
||||
// with a coupon_code condition matching a voucher in the grain;
|
||||
// matching vouchers get BypassedByPromotions set. If any voucher
|
||||
// was newly marked, re-run steps 2-4 (the live cart has done this
|
||||
// since the bypass pass; the preview must match exactly).
|
||||
// 6. ApplyResults records every effect (applied discounts + pending
|
||||
// "spend X more for ..." nudges) on the grain.
|
||||
//
|
||||
// The grain is mutated in place; for a read-only what-if, deep-copy
|
||||
// the grain first (see cmd/cart/promotions_evaluate.go's
|
||||
// cloneCartForPreview).
|
||||
func (s *PromotionService) EvaluateAndApply(rules []PromotionRule, g *cart.CartGrain, opts ...ContextOption) {
|
||||
for _, v := range g.Vouchers {
|
||||
if v != nil {
|
||||
v.BypassedByPromotions = false
|
||||
}
|
||||
}
|
||||
if len(opts) == 0 {
|
||||
opts = []ContextOption{WithNow(time.Now())}
|
||||
}
|
||||
g.UpdateTotals()
|
||||
ctx := NewContextFromCart(g, opts...)
|
||||
results, _ := s.EvaluateAll(rules, ctx)
|
||||
if markCouponsBypassed(g, results) {
|
||||
g.UpdateTotals()
|
||||
ctx = NewContextFromCart(g, opts...)
|
||||
results, _ = s.EvaluateAll(rules, ctx)
|
||||
}
|
||||
s.ApplyResults(g, results, ctx)
|
||||
// Populate the per-line breakdown now that ApplyResults is done —
|
||||
// every effect has distributed per-line Discounts onto the grain's
|
||||
// items, so MapEvaluatedItems reads consistent state. Sits on the
|
||||
// canonical pipeline so callers (live-cart processor, the cart-aware
|
||||
// preview handler, internal/admin mutations) never have to remember
|
||||
// to refresh it themselves; downstream readers (UCP cart response,
|
||||
// legacy /cart HTTP handler, cart-mcp, AMQP mutation feed) see a
|
||||
// grain that already carries the per-line breakdown on its own
|
||||
// EvaluatedItems field.
|
||||
g.EvaluatedItems = cart.MapEvaluatedItems(g)
|
||||
}
|
||||
|
||||
// markCouponsBypassed scans the evaluation results for any applicable
|
||||
// rule with a coupon_code condition whose value matches a voucher in
|
||||
// the grain; marks matching vouchers' BypassedByPromotions flag and
|
||||
// returns true if any were newly marked. Called by EvaluateAndApply
|
||||
// between the first and second EvaluateAll pass — see that method's
|
||||
// doc for why the re-eval is needed.
|
||||
func markCouponsBypassed(g *cart.CartGrain, results []EvaluationResult) bool {
|
||||
hasBypassed := false
|
||||
for _, res := range results {
|
||||
if !res.Applicable {
|
||||
continue
|
||||
}
|
||||
WalkConditions(res.Rule.Conditions, func(c Condition) bool {
|
||||
bc, ok := c.(BaseCondition)
|
||||
if !ok || bc.Type != CondCouponCode {
|
||||
return true
|
||||
}
|
||||
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 && !v.BypassedByPromotions {
|
||||
v.BypassedByPromotions = true
|
||||
hasBypassed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return hasBypassed
|
||||
}
|
||||
|
||||
// toCart materialises the request into a throwaway CartGrain.
|
||||
// tp is an optional TaxProvider for the default VAT rate; when nil, falls back to 25 %.
|
||||
func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
|
||||
@@ -74,25 +189,7 @@ func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
|
||||
}
|
||||
g := cart.NewCartGrain(0, now)
|
||||
for _, it := range req.Items {
|
||||
qty := it.Quantity
|
||||
if qty == 0 {
|
||||
qty = 1
|
||||
}
|
||||
// it.VatRate is the request rate in raw percent (external input); convert
|
||||
// to the platform basis-point scale at this boundary.
|
||||
vat := int(math.Round(float64(it.VatRate) * 100))
|
||||
if vat == 0 {
|
||||
vat = defaultVatRate(tp)
|
||||
}
|
||||
item := &cart.CartItem{
|
||||
Sku: it.Sku,
|
||||
Quantity: qty,
|
||||
Price: *cart.NewPriceFromIncVat(it.PriceIncVat, vat),
|
||||
}
|
||||
if it.Category != "" {
|
||||
item.Meta = &cart.ItemMeta{Category: it.Category}
|
||||
}
|
||||
g.Items = append(g.Items, item)
|
||||
g.Items = append(g.Items, it.ToCartItem(tp))
|
||||
}
|
||||
if len(g.Items) == 0 && req.CartTotalIncVat > 0 {
|
||||
vat := defaultVatRate(tp)
|
||||
@@ -105,6 +202,41 @@ func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
|
||||
return g
|
||||
}
|
||||
|
||||
// ToCartItem converts this EvalItem into a CartItem. tp is an optional
|
||||
// TaxProvider for the default VAT rate; when nil, falls back to 25 %.
|
||||
// This is the canonical EvalItem→CartItem conversion — shared by the
|
||||
// stateless evaluator (req.toCart above) and any caller that needs to
|
||||
// materialise an EvalItem into a real cart grain (e.g. the
|
||||
// /promotions/evaluate-with-cart preview handler, which merges PDP
|
||||
// request items into a deep-copied live grain so the engine sees the
|
||||
// user's "what-if" line list on top of the cart's actual state). Keep
|
||||
// the math identical to the inline conversion that used to live in
|
||||
// req.toCart so the two paths can never drift.
|
||||
//
|
||||
// VatRate is the request rate in raw percent (external input); convert
|
||||
// to the platform basis-point scale (× 100, rounded) at this boundary.
|
||||
// Missing qty defaults to 1; missing VAT rate falls back to
|
||||
// defaultVatRate(tp). Optional Category lands in ItemMeta.
|
||||
func (it EvalItem) ToCartItem(tp cart.TaxProvider) *cart.CartItem {
|
||||
qty := it.Quantity
|
||||
if qty == 0 {
|
||||
qty = 1
|
||||
}
|
||||
vat := int(math.Round(float64(it.VatRate) * 100))
|
||||
if vat == 0 {
|
||||
vat = defaultVatRate(tp)
|
||||
}
|
||||
out := &cart.CartItem{
|
||||
Sku: it.Sku,
|
||||
Quantity: qty,
|
||||
Price: *cart.NewPriceFromIncVat(it.PriceIncVat, vat),
|
||||
}
|
||||
if it.Category != "" {
|
||||
out.Meta = &cart.ItemMeta{Category: it.Category}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// defaultVatRate returns the default VAT rate in basis points (2500 = 25%) from
|
||||
// the provider, or 2500 if none is configured.
|
||||
func defaultVatRate(tp cart.TaxProvider) int {
|
||||
@@ -114,8 +246,14 @@ func defaultVatRate(tp cart.TaxProvider) int {
|
||||
return tp.DefaultTaxRate("")
|
||||
}
|
||||
|
||||
// contextOptions maps the optional customer/time fields onto context options.
|
||||
func (req EvaluateRequest) contextOptions() []ContextOption {
|
||||
// ContextOptions maps the optional customer/time fields onto context
|
||||
// options. WithNow is only added when req.Now is explicitly set —
|
||||
// callers that need a default "now" should append WithNow(time.Now())
|
||||
// themselves (or pass no opts and let EvaluateAndApply default it).
|
||||
// Exported so callers that build a context from a request (e.g. the
|
||||
// /promotions/evaluate-with-cart preview handler) can use the same
|
||||
// mapping the stateless Evaluate path uses, keeping the two in sync.
|
||||
func (req EvaluateRequest) ContextOptions() []ContextOption {
|
||||
var opts []ContextOption
|
||||
if req.Now != nil {
|
||||
opts = append(opts, WithNow(*req.Now))
|
||||
|
||||
+46
-9
@@ -18,6 +18,7 @@ type Store struct {
|
||||
path string
|
||||
version int
|
||||
promotions []PromotionRule
|
||||
OnChange func(id string, isDelete bool)
|
||||
}
|
||||
|
||||
// NewStore loads the state file at path (an absent file yields an empty store)
|
||||
@@ -72,40 +73,72 @@ func (s *Store) Upsert(rule PromotionRule) (replaced bool, err error) {
|
||||
if rule.ID == "" {
|
||||
return false, fmt.Errorf("promotion id is required")
|
||||
}
|
||||
var cb func(string, bool)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
defer func() {
|
||||
s.mu.Unlock()
|
||||
if err == nil && cb != nil {
|
||||
cb(rule.ID, false)
|
||||
}
|
||||
}()
|
||||
cb = s.OnChange
|
||||
|
||||
for i := range s.promotions {
|
||||
if s.promotions[i].ID == rule.ID {
|
||||
s.promotions[i] = rule
|
||||
return true, s.saveLocked()
|
||||
replaced = true
|
||||
err = s.saveLocked()
|
||||
return
|
||||
}
|
||||
}
|
||||
s.promotions = append(s.promotions, rule)
|
||||
return false, s.saveLocked()
|
||||
replaced = false
|
||||
err = s.saveLocked()
|
||||
return
|
||||
}
|
||||
|
||||
// SetStatus changes the status of a rule and persists. Returns false if no rule
|
||||
// with the id exists.
|
||||
func (s *Store) SetStatus(id string, status PromotionStatus) (bool, error) {
|
||||
func (s *Store) SetStatus(id string, status PromotionStatus) (found bool, err error) {
|
||||
var cb func(string, bool)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
defer func() {
|
||||
s.mu.Unlock()
|
||||
if err == nil && found && cb != nil {
|
||||
cb(id, false)
|
||||
}
|
||||
}()
|
||||
cb = s.OnChange
|
||||
|
||||
for i := range s.promotions {
|
||||
if s.promotions[i].ID == id {
|
||||
s.promotions[i].Status = status
|
||||
return true, s.saveLocked()
|
||||
found = true
|
||||
err = s.saveLocked()
|
||||
return
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Delete removes the rule with the id and persists. Returns false if not found.
|
||||
func (s *Store) Delete(id string) (bool, error) {
|
||||
func (s *Store) Delete(id string) (found bool, err error) {
|
||||
var cb func(string, bool)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
defer func() {
|
||||
s.mu.Unlock()
|
||||
if err == nil && found && cb != nil {
|
||||
cb(id, true)
|
||||
}
|
||||
}()
|
||||
cb = s.OnChange
|
||||
|
||||
for i := range s.promotions {
|
||||
if s.promotions[i].ID == id {
|
||||
s.promotions = append(s.promotions[:i], s.promotions[i+1:]...)
|
||||
return true, s.saveLocked()
|
||||
found = true
|
||||
err = s.saveLocked()
|
||||
return
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
@@ -128,6 +161,10 @@ func (s *Store) saveLocked() error {
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // no-op once renamed
|
||||
if err := tmp.Chmod(0644); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
|
||||
+12
-2
@@ -263,10 +263,16 @@ func (h *RemoteHost[V]) GetActorIds() []uint64 {
|
||||
return reply.GetIds()
|
||||
}
|
||||
|
||||
func (h *RemoteHost[V]) AnnounceOwnership(ownerHost string, uids []uint64) {
|
||||
func (h *RemoteHost[V]) AnnounceOwnership(ownerHost string, uids []uint64, lastChanges []int64) {
|
||||
// lastChanges is parallel to uids and carries each grain's
|
||||
// UnixNano lastChange stamp at the moment of broadcast. Receivers
|
||||
// use it for first-spawn-wins arbitration on concurrent cold
|
||||
// cache first touch; -1 entries signal "no local grain to back
|
||||
// this id" and trigger the legacy "always accept remote" fallback.
|
||||
_, err := h.controlClient.AnnounceOwnership(context.Background(), &messages.OwnershipAnnounce{
|
||||
Host: ownerHost,
|
||||
Ids: uids,
|
||||
LastChanges: lastChanges,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ownership announce to %s failed: %v", h.host, err)
|
||||
@@ -276,10 +282,14 @@ func (h *RemoteHost[V]) AnnounceOwnership(ownerHost string, uids []uint64) {
|
||||
h.missedPings = 0
|
||||
}
|
||||
|
||||
func (h *RemoteHost[V]) AnnounceExpiry(uids []uint64) {
|
||||
func (h *RemoteHost[V]) AnnounceExpiry(uids []uint64, lastChanges []int64) {
|
||||
// lastChanges is a parallel UnixNano stamp slice; informational on
|
||||
// this path (expiry is unilateral from the broadcaster), kept on
|
||||
// the wire for symmetry with AnnounceOwnership.
|
||||
_, err := h.controlClient.AnnounceExpiry(context.Background(), &messages.ExpiryAnnounce{
|
||||
Host: h.host,
|
||||
Ids: uids,
|
||||
LastChanges: lastChanges,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("expiry announce to %s failed: %v", h.host, err)
|
||||
|
||||
@@ -84,7 +84,7 @@ func newTracerProvider() (*trace.TracerProvider, error) {
|
||||
return nil, err
|
||||
}
|
||||
return trace.NewTracerProvider(
|
||||
trace.WithBatcher(traceExporter, trace.WithBatchTimeout(time.Second)),
|
||||
trace.WithBatcher(traceExporter, trace.WithBatchTimeout(5*time.Second)),
|
||||
), nil
|
||||
}
|
||||
|
||||
@@ -93,5 +93,5 @@ func newMeterProvider() (*metric.MeterProvider, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter))), nil
|
||||
return metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter, metric.WithInterval(time.Second*15)))), nil
|
||||
}
|
||||
|
||||
@@ -95,6 +95,35 @@ message UpsertSubscriptionDetails {
|
||||
google.protobuf.Any data = 4;
|
||||
}
|
||||
|
||||
enum CartType {
|
||||
REGULAR = 0;
|
||||
WISHLIST = 1;
|
||||
OFFER = 2;
|
||||
}
|
||||
|
||||
message SetCartType {
|
||||
CartType type = 1;
|
||||
}
|
||||
|
||||
// PushToken is a generic, provider-agnostic push-delivery target. The cart layer
|
||||
// only stores what it was given — actual delivery (FCM, APNs, Web Push) is the
|
||||
// notifier's job (see pkg/cart/recovery.LoggingNotifier for the v0 contract).
|
||||
//
|
||||
// Platform is free-form (e.g. "fcm", "apns", "webpush") so callers can record
|
||||
// multiple devices per cart without forcing a schema decision here.
|
||||
message PushToken {
|
||||
string platform = 1;
|
||||
string token = 2;
|
||||
}
|
||||
|
||||
// SetRecoveryContact attaches the contact bundle used by the abandoned-cart
|
||||
// recovery flow. PUT-style: replaces the entire contact in one event — empty
|
||||
// strings or empty token lists are persisted as "no contact".
|
||||
message SetRecoveryContact {
|
||||
string email = 1;
|
||||
repeated PushToken push_tokens = 2;
|
||||
}
|
||||
|
||||
message Mutation {
|
||||
oneof type {
|
||||
ClearCartRequest clear_cart = 1;
|
||||
@@ -109,5 +138,8 @@ message Mutation {
|
||||
AddVoucher add_voucher = 20;
|
||||
RemoveVoucher remove_voucher = 21;
|
||||
UpsertSubscriptionDetails upsert_subscription_details = 22;
|
||||
SetCartType set_cart_type = 23;
|
||||
SetRecoveryContact set_recovery_contact = 24;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+419
-242
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.5
|
||||
// protoc v7.35.0
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v7.35.1
|
||||
// source: cart.proto
|
||||
|
||||
package cart_messages
|
||||
@@ -23,6 +23,55 @@ const (
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type CartType int32
|
||||
|
||||
const (
|
||||
CartType_REGULAR CartType = 0
|
||||
CartType_WISHLIST CartType = 1
|
||||
CartType_OFFER CartType = 2
|
||||
)
|
||||
|
||||
// Enum value maps for CartType.
|
||||
var (
|
||||
CartType_name = map[int32]string{
|
||||
0: "REGULAR",
|
||||
1: "WISHLIST",
|
||||
2: "OFFER",
|
||||
}
|
||||
CartType_value = map[string]int32{
|
||||
"REGULAR": 0,
|
||||
"WISHLIST": 1,
|
||||
"OFFER": 2,
|
||||
}
|
||||
)
|
||||
|
||||
func (x CartType) Enum() *CartType {
|
||||
p := new(CartType)
|
||||
*p = x
|
||||
return p
|
||||
}
|
||||
|
||||
func (x CartType) String() string {
|
||||
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||
}
|
||||
|
||||
func (CartType) Descriptor() protoreflect.EnumDescriptor {
|
||||
return file_cart_proto_enumTypes[0].Descriptor()
|
||||
}
|
||||
|
||||
func (CartType) Type() protoreflect.EnumType {
|
||||
return &file_cart_proto_enumTypes[0]
|
||||
}
|
||||
|
||||
func (x CartType) Number() protoreflect.EnumNumber {
|
||||
return protoreflect.EnumNumber(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CartType.Descriptor instead.
|
||||
func (CartType) EnumDescriptor() ([]byte, []int) {
|
||||
return file_cart_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
type ClearCartRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
@@ -878,6 +927,163 @@ func (x *UpsertSubscriptionDetails) GetData() *anypb.Any {
|
||||
return nil
|
||||
}
|
||||
|
||||
type SetCartType struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Type CartType `protobuf:"varint,1,opt,name=type,proto3,enum=cart_messages.CartType" json:"type,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SetCartType) Reset() {
|
||||
*x = SetCartType{}
|
||||
mi := &file_cart_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SetCartType) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SetCartType) ProtoMessage() {}
|
||||
|
||||
func (x *SetCartType) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_cart_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SetCartType.ProtoReflect.Descriptor instead.
|
||||
func (*SetCartType) Descriptor() ([]byte, []int) {
|
||||
return file_cart_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *SetCartType) GetType() CartType {
|
||||
if x != nil {
|
||||
return x.Type
|
||||
}
|
||||
return CartType_REGULAR
|
||||
}
|
||||
|
||||
// PushToken is a generic, provider-agnostic push-delivery target. The cart layer
|
||||
// only stores what it was given — actual delivery (FCM, APNs, Web Push) is the
|
||||
// notifier's job (see pkg/cart/recovery.LoggingNotifier for the v0 contract).
|
||||
//
|
||||
// Platform is free-form (e.g. "fcm", "apns", "webpush") so callers can record
|
||||
// multiple devices per cart without forcing a schema decision here.
|
||||
type PushToken struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Platform string `protobuf:"bytes,1,opt,name=platform,proto3" json:"platform,omitempty"`
|
||||
Token string `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *PushToken) Reset() {
|
||||
*x = PushToken{}
|
||||
mi := &file_cart_proto_msgTypes[13]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *PushToken) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PushToken) ProtoMessage() {}
|
||||
|
||||
func (x *PushToken) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_cart_proto_msgTypes[13]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PushToken.ProtoReflect.Descriptor instead.
|
||||
func (*PushToken) Descriptor() ([]byte, []int) {
|
||||
return file_cart_proto_rawDescGZIP(), []int{13}
|
||||
}
|
||||
|
||||
func (x *PushToken) GetPlatform() string {
|
||||
if x != nil {
|
||||
return x.Platform
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PushToken) GetToken() string {
|
||||
if x != nil {
|
||||
return x.Token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetRecoveryContact attaches the contact bundle used by the abandoned-cart
|
||||
// recovery flow. PUT-style: replaces the entire contact in one event — empty
|
||||
// strings or empty token lists are persisted as "no contact".
|
||||
type SetRecoveryContact struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"`
|
||||
PushTokens []*PushToken `protobuf:"bytes,2,rep,name=push_tokens,json=pushTokens,proto3" json:"push_tokens,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SetRecoveryContact) Reset() {
|
||||
*x = SetRecoveryContact{}
|
||||
mi := &file_cart_proto_msgTypes[14]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SetRecoveryContact) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SetRecoveryContact) ProtoMessage() {}
|
||||
|
||||
func (x *SetRecoveryContact) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_cart_proto_msgTypes[14]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SetRecoveryContact.ProtoReflect.Descriptor instead.
|
||||
func (*SetRecoveryContact) Descriptor() ([]byte, []int) {
|
||||
return file_cart_proto_rawDescGZIP(), []int{14}
|
||||
}
|
||||
|
||||
func (x *SetRecoveryContact) GetEmail() string {
|
||||
if x != nil {
|
||||
return x.Email
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SetRecoveryContact) GetPushTokens() []*PushToken {
|
||||
if x != nil {
|
||||
return x.PushTokens
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Mutation struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Types that are valid to be assigned to Type:
|
||||
@@ -894,6 +1100,8 @@ type Mutation struct {
|
||||
// *Mutation_AddVoucher
|
||||
// *Mutation_RemoveVoucher
|
||||
// *Mutation_UpsertSubscriptionDetails
|
||||
// *Mutation_SetCartType
|
||||
// *Mutation_SetRecoveryContact
|
||||
Type isMutation_Type `protobuf_oneof:"type"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -901,7 +1109,7 @@ type Mutation struct {
|
||||
|
||||
func (x *Mutation) Reset() {
|
||||
*x = Mutation{}
|
||||
mi := &file_cart_proto_msgTypes[12]
|
||||
mi := &file_cart_proto_msgTypes[15]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -913,7 +1121,7 @@ func (x *Mutation) String() string {
|
||||
func (*Mutation) ProtoMessage() {}
|
||||
|
||||
func (x *Mutation) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_cart_proto_msgTypes[12]
|
||||
mi := &file_cart_proto_msgTypes[15]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -926,7 +1134,7 @@ func (x *Mutation) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Mutation.ProtoReflect.Descriptor instead.
|
||||
func (*Mutation) Descriptor() ([]byte, []int) {
|
||||
return file_cart_proto_rawDescGZIP(), []int{12}
|
||||
return file_cart_proto_rawDescGZIP(), []int{15}
|
||||
}
|
||||
|
||||
func (x *Mutation) GetType() isMutation_Type {
|
||||
@@ -1044,6 +1252,24 @@ func (x *Mutation) GetUpsertSubscriptionDetails() *UpsertSubscriptionDetails {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Mutation) GetSetCartType() *SetCartType {
|
||||
if x != nil {
|
||||
if x, ok := x.Type.(*Mutation_SetCartType); ok {
|
||||
return x.SetCartType
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Mutation) GetSetRecoveryContact() *SetRecoveryContact {
|
||||
if x != nil {
|
||||
if x, ok := x.Type.(*Mutation_SetRecoveryContact); ok {
|
||||
return x.SetRecoveryContact
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isMutation_Type interface {
|
||||
isMutation_Type()
|
||||
}
|
||||
@@ -1096,6 +1322,14 @@ type Mutation_UpsertSubscriptionDetails struct {
|
||||
UpsertSubscriptionDetails *UpsertSubscriptionDetails `protobuf:"bytes,22,opt,name=upsert_subscription_details,json=upsertSubscriptionDetails,proto3,oneof"`
|
||||
}
|
||||
|
||||
type Mutation_SetCartType struct {
|
||||
SetCartType *SetCartType `protobuf:"bytes,23,opt,name=set_cart_type,json=setCartType,proto3,oneof"`
|
||||
}
|
||||
|
||||
type Mutation_SetRecoveryContact struct {
|
||||
SetRecoveryContact *SetRecoveryContact `protobuf:"bytes,24,opt,name=set_recovery_contact,json=setRecoveryContact,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*Mutation_ClearCart) isMutation_Type() {}
|
||||
|
||||
func (*Mutation_AddItem) isMutation_Type() {}
|
||||
@@ -1120,203 +1354,134 @@ func (*Mutation_RemoveVoucher) isMutation_Type() {}
|
||||
|
||||
func (*Mutation_UpsertSubscriptionDetails) isMutation_Type() {}
|
||||
|
||||
func (*Mutation_SetCartType) isMutation_Type() {}
|
||||
|
||||
func (*Mutation_SetRecoveryContact) isMutation_Type() {}
|
||||
|
||||
var File_cart_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_cart_proto_rawDesc = string([]byte{
|
||||
0x0a, 0x0a, 0x63, 0x61, 0x72, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x63, 0x61,
|
||||
0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x1a, 0x19, 0x67, 0x6f, 0x6f,
|
||||
0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61, 0x6e, 0x79,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
|
||||
0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x12, 0x0a, 0x10, 0x43, 0x6c, 0x65, 0x61, 0x72,
|
||||
0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xaa, 0x08, 0x0a, 0x07,
|
||||
0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x74, 0x65, 0x6d, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64,
|
||||
0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05,
|
||||
0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x70, 0x72, 0x69,
|
||||
0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63, 0x65, 0x18, 0x09,
|
||||
0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63, 0x65, 0x12, 0x10,
|
||||
0x0a, 0x03, 0x73, 0x6b, 0x75, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x6b, 0x75,
|
||||
0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
|
||||
0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x06, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74,
|
||||
0x6f, 0x63, 0x6b, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x73, 0x74, 0x6f, 0x63, 0x6b,
|
||||
0x12, 0x10, 0x0a, 0x03, 0x74, 0x61, 0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x74,
|
||||
0x61, 0x78, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x61, 0x74, 0x65,
|
||||
0x67, 0x6f, 0x72, 0x79, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x61, 0x74, 0x65,
|
||||
0x67, 0x6f, 0x72, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
|
||||
0x32, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72,
|
||||
0x79, 0x32, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x33, 0x18,
|
||||
0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x33,
|
||||
0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x34, 0x18, 0x11, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x34, 0x12, 0x1c,
|
||||
0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x18, 0x12, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x12, 0x1e, 0x0a, 0x0a,
|
||||
0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x0a, 0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x12, 0x20, 0x0a, 0x0b,
|
||||
0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1a,
|
||||
0x0a, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x18, 0x13, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x65,
|
||||
0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x14, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a,
|
||||
0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f,
|
||||
0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x15, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75,
|
||||
0x6e, 0x74, 0x72, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x61, 0x6c, 0x65, 0x53, 0x74, 0x61, 0x74,
|
||||
0x75, 0x73, 0x18, 0x18, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x61, 0x6c, 0x65, 0x53, 0x74,
|
||||
0x61, 0x74, 0x75, 0x73, 0x12, 0x1b, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x18, 0x0c,
|
||||
0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x88, 0x01,
|
||||
0x01, 0x12, 0x1d, 0x0a, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x18, 0x16, 0x20, 0x01,
|
||||
0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x88, 0x01, 0x01,
|
||||
0x12, 0x1f, 0x0a, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x18, 0x17, 0x20, 0x01,
|
||||
0x28, 0x0d, 0x48, 0x02, 0x52, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x88, 0x01,
|
||||
0x01, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x67, 0x6d, 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
|
||||
0x63, 0x67, 0x6d, 0x12, 0x4f, 0x0a, 0x12, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
|
||||
0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x48, 0x03, 0x52, 0x12, 0x72,
|
||||
0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d,
|
||||
0x65, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x72, 0x61, 0x5f, 0x6a, 0x73,
|
||||
0x6f, 0x6e, 0x18, 0x1b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x65, 0x78, 0x74, 0x72, 0x61, 0x4a,
|
||||
0x73, 0x6f, 0x6e, 0x12, 0x4d, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69,
|
||||
0x65, 0x6c, 0x64, 0x73, 0x18, 0x1c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x63, 0x61, 0x72,
|
||||
0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x49, 0x74,
|
||||
0x65, 0x6d, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45,
|
||||
0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c,
|
||||
0x64, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x69, 0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x5f,
|
||||
0x74, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x1d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69,
|
||||
0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x12,
|
||||
0x1b, 0x0a, 0x09, 0x64, 0x72, 0x6f, 0x70, 0x5f, 0x73, 0x68, 0x69, 0x70, 0x18, 0x1e, 0x20, 0x01,
|
||||
0x28, 0x08, 0x52, 0x08, 0x64, 0x72, 0x6f, 0x70, 0x53, 0x68, 0x69, 0x70, 0x1a, 0x3f, 0x0a, 0x11,
|
||||
0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72,
|
||||
0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
|
||||
0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x09, 0x0a,
|
||||
0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f,
|
||||
0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49,
|
||||
0x64, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f,
|
||||
0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x1c, 0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f,
|
||||
0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65,
|
||||
0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e,
|
||||
0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e,
|
||||
0x74, 0x69, 0x74, 0x79, 0x22, 0x23, 0x0a, 0x09, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49,
|
||||
0x64, 0x12, 0x16, 0x0a, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x4f, 0x0a, 0x0f, 0x4c, 0x69, 0x6e,
|
||||
0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02,
|
||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04,
|
||||
0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65,
|
||||
0x12, 0x18, 0x0a, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x22, 0x27, 0x0a, 0x15, 0x52, 0x65,
|
||||
0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b,
|
||||
0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52,
|
||||
0x02, 0x69, 0x64, 0x22, 0xc9, 0x01, 0x0a, 0x17, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49,
|
||||
0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12,
|
||||
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12,
|
||||
0x5d, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73,
|
||||
0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65,
|
||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74,
|
||||
0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x2e, 0x43,
|
||||
0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79,
|
||||
0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, 0x3f,
|
||||
0x0a, 0x11, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e,
|
||||
0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22,
|
||||
0x71, 0x0a, 0x11, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41,
|
||||
0x64, 0x64, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09,
|
||||
0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x09, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x6f, 0x72,
|
||||
0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e,
|
||||
0x63, 0x65, 0x22, 0x7c, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72,
|
||||
0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
|
||||
0x63, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x76, 0x6f,
|
||||
0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09,
|
||||
0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x20,
|
||||
0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x22, 0x1f, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65,
|
||||
0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69,
|
||||
0x64, 0x22, 0xa7, 0x01, 0x0a, 0x19, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73,
|
||||
0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12,
|
||||
0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x02, 0x69,
|
||||
0x64, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x69, 0x6e, 0x67,
|
||||
0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x65,
|
||||
0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x69, 0x67, 0x6e,
|
||||
0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73,
|
||||
0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x12, 0x28, 0x0a, 0x04, 0x64, 0x61,
|
||||
0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
|
||||
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x04,
|
||||
0x64, 0x61, 0x74, 0x61, 0x42, 0x05, 0x0a, 0x03, 0x5f, 0x69, 0x64, 0x22, 0xa8, 0x07, 0x0a, 0x08,
|
||||
0x4d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x40, 0x0a, 0x0a, 0x63, 0x6c, 0x65, 0x61,
|
||||
0x72, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x63,
|
||||
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x6c, 0x65,
|
||||
0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52,
|
||||
0x09, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x12, 0x33, 0x0a, 0x08, 0x61, 0x64,
|
||||
0x64, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x63,
|
||||
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64,
|
||||
0x49, 0x74, 0x65, 0x6d, 0x48, 0x00, 0x52, 0x07, 0x61, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12,
|
||||
0x3c, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
||||
0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x48,
|
||||
0x00, 0x52, 0x0a, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x48, 0x0a,
|
||||
0x0f, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x5f, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79,
|
||||
0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65,
|
||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51, 0x75, 0x61,
|
||||
0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x00, 0x52, 0x0e, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51,
|
||||
0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3a, 0x0a, 0x0b, 0x73, 0x65, 0x74, 0x5f, 0x75,
|
||||
0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63,
|
||||
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74,
|
||||
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x48, 0x00, 0x52, 0x09, 0x73, 0x65, 0x74, 0x55, 0x73, 0x65,
|
||||
0x72, 0x49, 0x64, 0x12, 0x4c, 0x0a, 0x11, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d,
|
||||
0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e,
|
||||
0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c,
|
||||
0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x48, 0x00,
|
||||
0x52, 0x0f, 0x6c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e,
|
||||
0x67, 0x12, 0x5f, 0x0a, 0x18, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x6c, 0x69, 0x6e, 0x65,
|
||||
0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x07, 0x20,
|
||||
0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
||||
0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74,
|
||||
0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x48, 0x00, 0x52, 0x15, 0x72, 0x65, 0x6d,
|
||||
0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69,
|
||||
0x6e, 0x67, 0x12, 0x51, 0x0a, 0x12, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x5f, 0x61, 0x64, 0x64, 0x65, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20,
|
||||
0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53,
|
||||
0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x65, 0x64,
|
||||
0x48, 0x00, 0x52, 0x11, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x41, 0x64, 0x64, 0x65, 0x64, 0x12, 0x66, 0x0a, 0x1b, 0x73, 0x65, 0x74, 0x5f, 0x6c, 0x69, 0x6e,
|
||||
0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69,
|
||||
0x65, 0x6c, 0x64, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x63, 0x61, 0x72,
|
||||
0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x4c, 0x69,
|
||||
0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c,
|
||||
0x64, 0x73, 0x48, 0x00, 0x52, 0x17, 0x73, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65,
|
||||
0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x3c, 0x0a,
|
||||
0x0b, 0x61, 0x64, 0x64, 0x5f, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x18, 0x14, 0x20, 0x01,
|
||||
0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
||||
0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x48, 0x00, 0x52,
|
||||
0x0a, 0x61, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x45, 0x0a, 0x0e, 0x72,
|
||||
0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x18, 0x15, 0x20,
|
||||
0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
||||
0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65,
|
||||
0x72, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68,
|
||||
0x65, 0x72, 0x12, 0x6a, 0x0a, 0x1b, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x5f, 0x73, 0x75, 0x62,
|
||||
0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c,
|
||||
0x73, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d,
|
||||
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75,
|
||||
0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c,
|
||||
0x73, 0x48, 0x00, 0x52, 0x19, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73, 0x63,
|
||||
0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x42, 0x06,
|
||||
0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x42, 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36,
|
||||
0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61,
|
||||
0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63,
|
||||
0x61, 0x72, 0x74, 0x3b, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
||||
0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
})
|
||||
const file_cart_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
"cart.proto\x12\rcart_messages\x1a\x19google/protobuf/any.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x12\n" +
|
||||
"\x10ClearCartRequest\"\xaa\b\n" +
|
||||
"\aAddItem\x12\x17\n" +
|
||||
"\aitem_id\x18\x01 \x01(\rR\x06itemId\x12\x1a\n" +
|
||||
"\bquantity\x18\x02 \x01(\x05R\bquantity\x12\x14\n" +
|
||||
"\x05price\x18\x03 \x01(\x03R\x05price\x12\x1a\n" +
|
||||
"\borgPrice\x18\t \x01(\x03R\borgPrice\x12\x10\n" +
|
||||
"\x03sku\x18\x04 \x01(\tR\x03sku\x12\x12\n" +
|
||||
"\x04name\x18\x05 \x01(\tR\x04name\x12\x14\n" +
|
||||
"\x05image\x18\x06 \x01(\tR\x05image\x12\x14\n" +
|
||||
"\x05stock\x18\a \x01(\x05R\x05stock\x12\x10\n" +
|
||||
"\x03tax\x18\b \x01(\x05R\x03tax\x12\x14\n" +
|
||||
"\x05brand\x18\r \x01(\tR\x05brand\x12\x1a\n" +
|
||||
"\bcategory\x18\x0e \x01(\tR\bcategory\x12\x1c\n" +
|
||||
"\tcategory2\x18\x0f \x01(\tR\tcategory2\x12\x1c\n" +
|
||||
"\tcategory3\x18\x10 \x01(\tR\tcategory3\x12\x1c\n" +
|
||||
"\tcategory4\x18\x11 \x01(\tR\tcategory4\x12\x1c\n" +
|
||||
"\tcategory5\x18\x12 \x01(\tR\tcategory5\x12\x1e\n" +
|
||||
"\n" +
|
||||
"disclaimer\x18\n" +
|
||||
" \x01(\tR\n" +
|
||||
"disclaimer\x12 \n" +
|
||||
"\varticleType\x18\v \x01(\tR\varticleType\x12\x1a\n" +
|
||||
"\bsellerId\x18\x13 \x01(\tR\bsellerId\x12\x1e\n" +
|
||||
"\n" +
|
||||
"sellerName\x18\x14 \x01(\tR\n" +
|
||||
"sellerName\x12\x18\n" +
|
||||
"\acountry\x18\x15 \x01(\tR\acountry\x12\x1e\n" +
|
||||
"\n" +
|
||||
"saleStatus\x18\x18 \x01(\tR\n" +
|
||||
"saleStatus\x12\x1b\n" +
|
||||
"\x06outlet\x18\f \x01(\tH\x00R\x06outlet\x88\x01\x01\x12\x1d\n" +
|
||||
"\astoreId\x18\x16 \x01(\tH\x01R\astoreId\x88\x01\x01\x12\x1f\n" +
|
||||
"\bparentId\x18\x17 \x01(\rH\x02R\bparentId\x88\x01\x01\x12\x10\n" +
|
||||
"\x03cgm\x18\x19 \x01(\tR\x03cgm\x12O\n" +
|
||||
"\x12reservationEndTime\x18\x1a \x01(\v2\x1a.google.protobuf.TimestampH\x03R\x12reservationEndTime\x88\x01\x01\x12\x1d\n" +
|
||||
"\n" +
|
||||
"extra_json\x18\x1b \x01(\fR\textraJson\x12M\n" +
|
||||
"\rcustom_fields\x18\x1c \x03(\v2(.cart_messages.AddItem.CustomFieldsEntryR\fcustomFields\x12+\n" +
|
||||
"\x11inventory_tracked\x18\x1d \x01(\bR\x10inventoryTracked\x12\x1b\n" +
|
||||
"\tdrop_ship\x18\x1e \x01(\bR\bdropShip\x1a?\n" +
|
||||
"\x11CustomFieldsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\t\n" +
|
||||
"\a_outletB\n" +
|
||||
"\n" +
|
||||
"\b_storeIdB\v\n" +
|
||||
"\t_parentIdB\x15\n" +
|
||||
"\x13_reservationEndTime\"\x1c\n" +
|
||||
"\n" +
|
||||
"RemoveItem\x12\x0e\n" +
|
||||
"\x02Id\x18\x01 \x01(\rR\x02Id\"<\n" +
|
||||
"\x0eChangeQuantity\x12\x0e\n" +
|
||||
"\x02Id\x18\x01 \x01(\rR\x02Id\x12\x1a\n" +
|
||||
"\bquantity\x18\x02 \x01(\x05R\bquantity\"#\n" +
|
||||
"\tSetUserId\x12\x16\n" +
|
||||
"\x06userId\x18\x01 \x01(\tR\x06userId\"O\n" +
|
||||
"\x0fLineItemMarking\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\rR\x02id\x12\x12\n" +
|
||||
"\x04type\x18\x02 \x01(\rR\x04type\x12\x18\n" +
|
||||
"\amarking\x18\x03 \x01(\tR\amarking\"'\n" +
|
||||
"\x15RemoveLineItemMarking\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\rR\x02id\"\xc9\x01\n" +
|
||||
"\x17SetLineItemCustomFields\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\rR\x02id\x12]\n" +
|
||||
"\rcustom_fields\x18\x02 \x03(\v28.cart_messages.SetLineItemCustomFields.CustomFieldsEntryR\fcustomFields\x1a?\n" +
|
||||
"\x11CustomFieldsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"q\n" +
|
||||
"\x11SubscriptionAdded\x12\x16\n" +
|
||||
"\x06itemId\x18\x01 \x01(\rR\x06itemId\x12\x1c\n" +
|
||||
"\tdetailsId\x18\x03 \x01(\tR\tdetailsId\x12&\n" +
|
||||
"\x0eorderReference\x18\x04 \x01(\tR\x0eorderReference\"|\n" +
|
||||
"\n" +
|
||||
"AddVoucher\x12\x12\n" +
|
||||
"\x04code\x18\x01 \x01(\tR\x04code\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\x03R\x05value\x12\"\n" +
|
||||
"\fvoucherRules\x18\x03 \x03(\tR\fvoucherRules\x12 \n" +
|
||||
"\vdescription\x18\x04 \x01(\tR\vdescription\"\x1f\n" +
|
||||
"\rRemoveVoucher\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\rR\x02id\"\xa7\x01\n" +
|
||||
"\x19UpsertSubscriptionDetails\x12\x13\n" +
|
||||
"\x02id\x18\x01 \x01(\tH\x00R\x02id\x88\x01\x01\x12\"\n" +
|
||||
"\fofferingCode\x18\x02 \x01(\tR\fofferingCode\x12 \n" +
|
||||
"\vsigningType\x18\x03 \x01(\tR\vsigningType\x12(\n" +
|
||||
"\x04data\x18\x04 \x01(\v2\x14.google.protobuf.AnyR\x04dataB\x05\n" +
|
||||
"\x03_id\":\n" +
|
||||
"\vSetCartType\x12+\n" +
|
||||
"\x04type\x18\x01 \x01(\x0e2\x17.cart_messages.CartTypeR\x04type\"=\n" +
|
||||
"\tPushToken\x12\x1a\n" +
|
||||
"\bplatform\x18\x01 \x01(\tR\bplatform\x12\x14\n" +
|
||||
"\x05token\x18\x02 \x01(\tR\x05token\"e\n" +
|
||||
"\x12SetRecoveryContact\x12\x14\n" +
|
||||
"\x05email\x18\x01 \x01(\tR\x05email\x129\n" +
|
||||
"\vpush_tokens\x18\x02 \x03(\v2\x18.cart_messages.PushTokenR\n" +
|
||||
"pushTokens\"\xc1\b\n" +
|
||||
"\bMutation\x12@\n" +
|
||||
"\n" +
|
||||
"clear_cart\x18\x01 \x01(\v2\x1f.cart_messages.ClearCartRequestH\x00R\tclearCart\x123\n" +
|
||||
"\badd_item\x18\x02 \x01(\v2\x16.cart_messages.AddItemH\x00R\aaddItem\x12<\n" +
|
||||
"\vremove_item\x18\x03 \x01(\v2\x19.cart_messages.RemoveItemH\x00R\n" +
|
||||
"removeItem\x12H\n" +
|
||||
"\x0fchange_quantity\x18\x04 \x01(\v2\x1d.cart_messages.ChangeQuantityH\x00R\x0echangeQuantity\x12:\n" +
|
||||
"\vset_user_id\x18\x05 \x01(\v2\x18.cart_messages.SetUserIdH\x00R\tsetUserId\x12L\n" +
|
||||
"\x11line_item_marking\x18\x06 \x01(\v2\x1e.cart_messages.LineItemMarkingH\x00R\x0flineItemMarking\x12_\n" +
|
||||
"\x18remove_line_item_marking\x18\a \x01(\v2$.cart_messages.RemoveLineItemMarkingH\x00R\x15removeLineItemMarking\x12Q\n" +
|
||||
"\x12subscription_added\x18\b \x01(\v2 .cart_messages.SubscriptionAddedH\x00R\x11subscriptionAdded\x12f\n" +
|
||||
"\x1bset_line_item_custom_fields\x18\t \x01(\v2&.cart_messages.SetLineItemCustomFieldsH\x00R\x17setLineItemCustomFields\x12<\n" +
|
||||
"\vadd_voucher\x18\x14 \x01(\v2\x19.cart_messages.AddVoucherH\x00R\n" +
|
||||
"addVoucher\x12E\n" +
|
||||
"\x0eremove_voucher\x18\x15 \x01(\v2\x1c.cart_messages.RemoveVoucherH\x00R\rremoveVoucher\x12j\n" +
|
||||
"\x1bupsert_subscription_details\x18\x16 \x01(\v2(.cart_messages.UpsertSubscriptionDetailsH\x00R\x19upsertSubscriptionDetails\x12@\n" +
|
||||
"\rset_cart_type\x18\x17 \x01(\v2\x1a.cart_messages.SetCartTypeH\x00R\vsetCartType\x12U\n" +
|
||||
"\x14set_recovery_contact\x18\x18 \x01(\v2!.cart_messages.SetRecoveryContactH\x00R\x12setRecoveryContactB\x06\n" +
|
||||
"\x04type*0\n" +
|
||||
"\bCartType\x12\v\n" +
|
||||
"\aREGULAR\x10\x00\x12\f\n" +
|
||||
"\bWISHLIST\x10\x01\x12\t\n" +
|
||||
"\x05OFFER\x10\x02B9Z7git.k6n.net/mats/go-cart-actor/proto/cart;cart_messagesb\x06proto3"
|
||||
|
||||
var (
|
||||
file_cart_proto_rawDescOnce sync.Once
|
||||
@@ -1330,48 +1495,57 @@ func file_cart_proto_rawDescGZIP() []byte {
|
||||
return file_cart_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_cart_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
|
||||
var file_cart_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_cart_proto_msgTypes = make([]protoimpl.MessageInfo, 18)
|
||||
var file_cart_proto_goTypes = []any{
|
||||
(*ClearCartRequest)(nil), // 0: cart_messages.ClearCartRequest
|
||||
(*AddItem)(nil), // 1: cart_messages.AddItem
|
||||
(*RemoveItem)(nil), // 2: cart_messages.RemoveItem
|
||||
(*ChangeQuantity)(nil), // 3: cart_messages.ChangeQuantity
|
||||
(*SetUserId)(nil), // 4: cart_messages.SetUserId
|
||||
(*LineItemMarking)(nil), // 5: cart_messages.LineItemMarking
|
||||
(*RemoveLineItemMarking)(nil), // 6: cart_messages.RemoveLineItemMarking
|
||||
(*SetLineItemCustomFields)(nil), // 7: cart_messages.SetLineItemCustomFields
|
||||
(*SubscriptionAdded)(nil), // 8: cart_messages.SubscriptionAdded
|
||||
(*AddVoucher)(nil), // 9: cart_messages.AddVoucher
|
||||
(*RemoveVoucher)(nil), // 10: cart_messages.RemoveVoucher
|
||||
(*UpsertSubscriptionDetails)(nil), // 11: cart_messages.UpsertSubscriptionDetails
|
||||
(*Mutation)(nil), // 12: cart_messages.Mutation
|
||||
nil, // 13: cart_messages.AddItem.CustomFieldsEntry
|
||||
nil, // 14: cart_messages.SetLineItemCustomFields.CustomFieldsEntry
|
||||
(*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp
|
||||
(*anypb.Any)(nil), // 16: google.protobuf.Any
|
||||
(CartType)(0), // 0: cart_messages.CartType
|
||||
(*ClearCartRequest)(nil), // 1: cart_messages.ClearCartRequest
|
||||
(*AddItem)(nil), // 2: cart_messages.AddItem
|
||||
(*RemoveItem)(nil), // 3: cart_messages.RemoveItem
|
||||
(*ChangeQuantity)(nil), // 4: cart_messages.ChangeQuantity
|
||||
(*SetUserId)(nil), // 5: cart_messages.SetUserId
|
||||
(*LineItemMarking)(nil), // 6: cart_messages.LineItemMarking
|
||||
(*RemoveLineItemMarking)(nil), // 7: cart_messages.RemoveLineItemMarking
|
||||
(*SetLineItemCustomFields)(nil), // 8: cart_messages.SetLineItemCustomFields
|
||||
(*SubscriptionAdded)(nil), // 9: cart_messages.SubscriptionAdded
|
||||
(*AddVoucher)(nil), // 10: cart_messages.AddVoucher
|
||||
(*RemoveVoucher)(nil), // 11: cart_messages.RemoveVoucher
|
||||
(*UpsertSubscriptionDetails)(nil), // 12: cart_messages.UpsertSubscriptionDetails
|
||||
(*SetCartType)(nil), // 13: cart_messages.SetCartType
|
||||
(*PushToken)(nil), // 14: cart_messages.PushToken
|
||||
(*SetRecoveryContact)(nil), // 15: cart_messages.SetRecoveryContact
|
||||
(*Mutation)(nil), // 16: cart_messages.Mutation
|
||||
nil, // 17: cart_messages.AddItem.CustomFieldsEntry
|
||||
nil, // 18: cart_messages.SetLineItemCustomFields.CustomFieldsEntry
|
||||
(*timestamppb.Timestamp)(nil), // 19: google.protobuf.Timestamp
|
||||
(*anypb.Any)(nil), // 20: google.protobuf.Any
|
||||
}
|
||||
var file_cart_proto_depIdxs = []int32{
|
||||
15, // 0: cart_messages.AddItem.reservationEndTime:type_name -> google.protobuf.Timestamp
|
||||
13, // 1: cart_messages.AddItem.custom_fields:type_name -> cart_messages.AddItem.CustomFieldsEntry
|
||||
14, // 2: cart_messages.SetLineItemCustomFields.custom_fields:type_name -> cart_messages.SetLineItemCustomFields.CustomFieldsEntry
|
||||
16, // 3: cart_messages.UpsertSubscriptionDetails.data:type_name -> google.protobuf.Any
|
||||
0, // 4: cart_messages.Mutation.clear_cart:type_name -> cart_messages.ClearCartRequest
|
||||
1, // 5: cart_messages.Mutation.add_item:type_name -> cart_messages.AddItem
|
||||
2, // 6: cart_messages.Mutation.remove_item:type_name -> cart_messages.RemoveItem
|
||||
3, // 7: cart_messages.Mutation.change_quantity:type_name -> cart_messages.ChangeQuantity
|
||||
4, // 8: cart_messages.Mutation.set_user_id:type_name -> cart_messages.SetUserId
|
||||
5, // 9: cart_messages.Mutation.line_item_marking:type_name -> cart_messages.LineItemMarking
|
||||
6, // 10: cart_messages.Mutation.remove_line_item_marking:type_name -> cart_messages.RemoveLineItemMarking
|
||||
8, // 11: cart_messages.Mutation.subscription_added:type_name -> cart_messages.SubscriptionAdded
|
||||
7, // 12: cart_messages.Mutation.set_line_item_custom_fields:type_name -> cart_messages.SetLineItemCustomFields
|
||||
9, // 13: cart_messages.Mutation.add_voucher:type_name -> cart_messages.AddVoucher
|
||||
10, // 14: cart_messages.Mutation.remove_voucher:type_name -> cart_messages.RemoveVoucher
|
||||
11, // 15: cart_messages.Mutation.upsert_subscription_details:type_name -> cart_messages.UpsertSubscriptionDetails
|
||||
16, // [16:16] is the sub-list for method output_type
|
||||
16, // [16:16] is the sub-list for method input_type
|
||||
16, // [16:16] is the sub-list for extension type_name
|
||||
16, // [16:16] is the sub-list for extension extendee
|
||||
0, // [0:16] is the sub-list for field type_name
|
||||
19, // 0: cart_messages.AddItem.reservationEndTime:type_name -> google.protobuf.Timestamp
|
||||
17, // 1: cart_messages.AddItem.custom_fields:type_name -> cart_messages.AddItem.CustomFieldsEntry
|
||||
18, // 2: cart_messages.SetLineItemCustomFields.custom_fields:type_name -> cart_messages.SetLineItemCustomFields.CustomFieldsEntry
|
||||
20, // 3: cart_messages.UpsertSubscriptionDetails.data:type_name -> google.protobuf.Any
|
||||
0, // 4: cart_messages.SetCartType.type:type_name -> cart_messages.CartType
|
||||
14, // 5: cart_messages.SetRecoveryContact.push_tokens:type_name -> cart_messages.PushToken
|
||||
1, // 6: cart_messages.Mutation.clear_cart:type_name -> cart_messages.ClearCartRequest
|
||||
2, // 7: cart_messages.Mutation.add_item:type_name -> cart_messages.AddItem
|
||||
3, // 8: cart_messages.Mutation.remove_item:type_name -> cart_messages.RemoveItem
|
||||
4, // 9: cart_messages.Mutation.change_quantity:type_name -> cart_messages.ChangeQuantity
|
||||
5, // 10: cart_messages.Mutation.set_user_id:type_name -> cart_messages.SetUserId
|
||||
6, // 11: cart_messages.Mutation.line_item_marking:type_name -> cart_messages.LineItemMarking
|
||||
7, // 12: cart_messages.Mutation.remove_line_item_marking:type_name -> cart_messages.RemoveLineItemMarking
|
||||
9, // 13: cart_messages.Mutation.subscription_added:type_name -> cart_messages.SubscriptionAdded
|
||||
8, // 14: cart_messages.Mutation.set_line_item_custom_fields:type_name -> cart_messages.SetLineItemCustomFields
|
||||
10, // 15: cart_messages.Mutation.add_voucher:type_name -> cart_messages.AddVoucher
|
||||
11, // 16: cart_messages.Mutation.remove_voucher:type_name -> cart_messages.RemoveVoucher
|
||||
12, // 17: cart_messages.Mutation.upsert_subscription_details:type_name -> cart_messages.UpsertSubscriptionDetails
|
||||
13, // 18: cart_messages.Mutation.set_cart_type:type_name -> cart_messages.SetCartType
|
||||
15, // 19: cart_messages.Mutation.set_recovery_contact:type_name -> cart_messages.SetRecoveryContact
|
||||
20, // [20:20] is the sub-list for method output_type
|
||||
20, // [20:20] is the sub-list for method input_type
|
||||
20, // [20:20] is the sub-list for extension type_name
|
||||
20, // [20:20] is the sub-list for extension extendee
|
||||
0, // [0:20] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_cart_proto_init() }
|
||||
@@ -1381,7 +1555,7 @@ func file_cart_proto_init() {
|
||||
}
|
||||
file_cart_proto_msgTypes[1].OneofWrappers = []any{}
|
||||
file_cart_proto_msgTypes[11].OneofWrappers = []any{}
|
||||
file_cart_proto_msgTypes[12].OneofWrappers = []any{
|
||||
file_cart_proto_msgTypes[15].OneofWrappers = []any{
|
||||
(*Mutation_ClearCart)(nil),
|
||||
(*Mutation_AddItem)(nil),
|
||||
(*Mutation_RemoveItem)(nil),
|
||||
@@ -1394,19 +1568,22 @@ func file_cart_proto_init() {
|
||||
(*Mutation_AddVoucher)(nil),
|
||||
(*Mutation_RemoveVoucher)(nil),
|
||||
(*Mutation_UpsertSubscriptionDetails)(nil),
|
||||
(*Mutation_SetCartType)(nil),
|
||||
(*Mutation_SetRecoveryContact)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_cart_proto_rawDesc), len(file_cart_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 15,
|
||||
NumEnums: 1,
|
||||
NumMessages: 18,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
GoTypes: file_cart_proto_goTypes,
|
||||
DependencyIndexes: file_cart_proto_depIdxs,
|
||||
EnumInfos: file_cart_proto_enumTypes,
|
||||
MessageInfos: file_cart_proto_msgTypes,
|
||||
}.Build()
|
||||
File_cart_proto = out.File
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.5
|
||||
// protoc v7.35.0
|
||||
// protoc v7.35.1
|
||||
// source: checkout.proto
|
||||
|
||||
package checkout_messages
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user